diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2b562cc321..cabc6211ff 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -9,7 +9,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — ## Sources of truth -- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): repository and package rules. +- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): standing repository and package authoring contracts. - [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. - [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings. @@ -22,26 +22,27 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 1. **New prose receives semantic review.** Use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) to critically review every added or changed Markdown passage, JSDoc, comment, prompt, description, diagnostic, and visible string. Verify required coverage, accuracy, placement, and editorial quality against the owning code or behavior; automated checks do not establish those properties. 2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the package README and JSDoc in the same diff. Comments state non-obvious contracts; flag implementation narration, test walkthroughs, review history, and duplicated rationale for deletion or a link to their one home. 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry. -4. **Registrations clean up.** A new registry contribution has a test that disposes its owner and observes removal. +4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). 5. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. ## Manual checks - **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any RFC, including errors, cancellation, ownership, and disposal. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. -- **Capability shape:** a swappable capability follows the interface / implementation / consumer split. Consumers depend on the interface, not a backend. -- **Scope, ownership, and necessity:** tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer. Challenge unrelated features, speculative generality, and behavior placed outside its owning plugin or service. -- **Configuration:** deployment-varying timeouts, caps, models, URLs, paths, and retry counts are validated `Config` fields, not literals or `DEFAULT_*` constants. -- **Enforcement boundaries:** hidden schema fields, filtered prompts, facades, wrappers, and listener ordering are not authoritative enforcement when direct or alternate callers can bypass them. Exercise denial paths at the boundary that actually executes the operation. -- **Borrowed and derived state:** determine whether retained caller-owned values are borrowed or snapshotted by contract; do not demand copies at typed same-process seams. Materialize mutable values that cross queues, model/tool JSON, durable logs or files, workers, processes, or wire boundaries. Commit notifications and derived state only at the documented success boundary, and trace caches, prompts, UI echoes, replay, and query views to one authoritative source. -- **Bounds cover the final operation:** verify byte, token, item, and time limits at the boundary that owns the complete emitted or retained result, including wrappers and metadata. Probe tiny limits, exact thresholds, oversized single chunks, and multibyte text for byte limits. +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). +- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR's coherence against [the root contract](../../../AGENTS.md#conventions). +- **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. +- **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. +- **Enforcement boundaries:** follow every denial path to the operation that executes it; exercise direct and alternate callers that can bypass schemas, prompts, facades, wrappers, or listener ordering. +- **Borrowed and derived state:** classify each retained value under the package boundary contract, then trace notifications and every cache, prompt, UI echo, replay, and query view to the documented success point and authoritative source. +- **Bounds cover the final operation:** locate the owner of the complete emitted or retained result, including wrappers and metadata. Probe tiny and exact limits, oversized single chunks, and multibyte text for byte limits. - **Real entry path:** tests exercise the shipped Loader, bin, worker, ACP bridge, or subprocess where relevant. A hand-mounted plugin does not catch Loader export-shape failures; a function plugin must named-export its namespace and have no default export. - **Test strength:** assertions fail on the intended regression and verify external state, logs, events, or disposal rather than restating the implementation or trusting an agent's report. Coverage is necessary but not evidence that the scenario is correct. -- **Changed checks have a negative control:** a new automated check, or a changed acceptance path in one, has a deliberately invalid case that reaches the real top-level runner and fails for the intended rule; a green happy path does not prove the check is wired. +- **Mechanized invariants and negative controls:** trace each new or changed check through the executed top-level gate and its deliberately invalid case; confirm the real runner fails for the intended rule. - **Implemented RFCs match shipped reality:** when a PR implements a proposed RFC, move and rewrite it as present-tense shipped state in the same diff, then verify paths, names, and mechanisms against the implementation. - **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review golden diffs as behavior changes, not formatting noise. - **Bilingual changes:** compare meaning and terminology on both sides; a green pairing hash does not prove translation quality. ## Reporting findings -State the defect, location, impact, and evidence. Separate blockers from suggestions and omit issues already enforced by a green gate. Use the existing GitHub review thread for replies. When receiving review, verify each claim and fix or rebut it on technical grounds without performative agreement. +State the defect, location, impact, and evidence. Place a localized defect inline on the tightest relevant diff range; use a PR-level comment for cross-cutting architecture, scope, or review-wide synthesis. Separate blockers from suggestions and omit issues already enforced by a green gate. Use the existing GitHub review thread for replies. When receiving review, verify each claim and fix or rebut it on technical grounds without performative agreement. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 4ce005ee79..433aa38942 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -54,7 +54,7 @@ pnpm run test:snapshot Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. ```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts ``` Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. diff --git a/AGENTS.md b/AGENTS.md index e551a7de10..27b5a655e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/JSON-RPC bridges; boot, approval, interaction plugins - examples/ demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) leaves load + ui/ ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins + examples/ demo bundles (agent-spine + stdio/CLI/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities python/ Python SDK and bundled runtime (see python/README.md) @@ -47,7 +47,7 @@ pnpm install # pnpm workspaces, node ^22.19 || >=24 pnpm run test # vitest unit tests pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY -pnpm run test:snapshot # keyless ACP replay vs goldens; filter: -t +pnpm run test:snapshot # keyless ACP/headless/TUI replay vs goldens; filter: -t pnpm run test:snapshot:record # re-record goldens (needs key) pnpm run typecheck pnpm run lint @@ -58,6 +58,8 @@ pnpm run doc-sync # all documentation gates; see the doc-sync script in pa pnpm run website:build # VitePress build (doubles as the site's dead-link check) pnpm run demo:echo # mock-model REPL, no key needed pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # one-shot agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` @@ -87,7 +89,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/examples/stdio-demo/tests/built-bin.e2e.ts packages/examples/cli-demo/tests/built-bin.e2e.ts packages/examples/acp-demo/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. @@ -108,7 +110,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. - **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively. - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template). -- **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. +- **No hardcoded tunables in plugins**: deployment-varying choices are validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. @@ -118,7 +120,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces, and schedule any missing harness support before implementation. -- **Merge PRs with merge commits**, never squash/rebase or rewrite pushed branches. Put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. @@ -130,7 +132,7 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class. -Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified exceptions rather than disabling a rule globally. +Comments and docs preserve complete contracts and non-obvious orientation, not reasoning transcripts. Do not narrate control flow or tests, preserve review history, or restate code. Keep factual clauses affecting behavior, failure, timing, ownership, or safe use; link aggressively to owning rationale. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Wire mechanically checkable invariants into an executed top-level gate and prove each new or changed acceptance path rejects an invalid case. Use narrow justified exceptions instead of disabling a rule globally. Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). diff --git a/README.i18n.yaml b/README.i18n.yaml index 790812344d..64e212ff3a 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 53dd3896eb15800125673e7c44f7de02daca9376 -README.zh.md: ab826f62658248249ec18c57b35c0065c0f909d1 +README.md: ef9a3a8832d1eaa35ec5f0fed1780ab27e8ff37c +README.zh.md: a30d6db4b04f23559c36a7aba80b4feb2962a1c6 diff --git a/README.md b/README.md index 53dd3896eb..ef9a3a8832 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,11 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:echo # keyless mock-model REPL +pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index ab826f6265..a30d6db4b0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,11 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:echo # keyless mock-model REPL +pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:headless -- "task" # one-shot coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 0c305e87c8..a0c30c5545 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. -- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. @@ -43,7 +43,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. +Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except `packages/AGENTS.md` ≤ 650 and this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. ## The slop checklist diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index b9292aa80e..888b24043a 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -32,14 +32,31 @@ sequenceDiagram LLM-->>Driver: StreamChunk* Driver->>Session: assistant/chunk* Session-->>SDK: session/event assistant/chunk* + alt final adapter or terminal in-band request failure + Driver->>Session: step/end + Driver->>Hooks: agent/request-error waterfall + Hooks-->>Driver: retry in a new step or preserve the original error + else model request succeeded Driver->>Hooks: agent/step-result waterfall Driver->>Session: assistant/message - Driver->>Session: tool/call - Driver->>Tools: execute through pre and post waterfalls - Tools-->>Session: tool-owned events when applicable - Driver->>Session: tool/result and step/end + Driver->>Tools: classify pending call by executionMode + loop barriers and bounded rolling pool, reclassify before start + opt call starts + Driver->>Session: tool/call + Driver->>Tools: ordered pre, concurrent execute + Tools-->>Session: tool-owned events when applicable + end + opt next model-order result ready + Driver->>Tools: ordered post + Driver->>Session: tool/result + end + end + Driver->>Session: post-tool context and steering + Driver->>Hooks: agent/post-step serial checkpoint + Driver->>Session: step/end Driver->>Hooks: agent/turn-continuation waterfall Driver->>Hooks: agent/turn-stop serial terminal checkpoint + end Driver->>Session: turn/end Driver->>Persistence: session/flush parallel checkpoint Driver-->>SDK: agent/status idle @@ -47,6 +64,8 @@ sequenceDiagram The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set. +`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative. + SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors. Maintenance mode: curated Mermaid sequence; exact event signatures live in the generated Cordis catalog. diff --git a/docs/architecture.md b/docs/architecture.md index 48f51db93d..fd21648dd2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages contribute service keys, typed events, and disposable registrations: services expose stable calls (`ctx.llm`, `ctx.tools`, `ctx.sessions`), events provide interception and notifications (`agent/request`, `tools/pre-execute`, `session/event`), and registrations install prompt sections, tools, providers, adapters, or listeners. +A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx.llm`, `ctx.tools`, `ctx.sessions`), typed events (`agent/request`, `tools/pre-execute`, `session/event`), and disposable prompt, tool, provider, adapter, and listener registrations. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -16,8 +16,8 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions | | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | -| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | +| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, and process-local initiator scope | +| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services @@ -56,12 +56,15 @@ Waterfall events behave like around-middleware: a listener delegates by calling The shipped loop drains work from prompt through checkpoint. Every pause is a service call or event available to plugins. -A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. + +Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. ### Turn Flow ```text -prepare private session + agent.ctx -> await unpublished setup +choose declarative identity and fresh/resume path + -> prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -77,32 +80,43 @@ forever: assemble system prompt and tool schemas agent/session-prefix (first step) agent/pre-step - 'step/start' snapshot the derived messages (the reconstruction boundary) + 'step/start' agent/request (config only) -> log request/header -> llm/stream (frozen) + on final adapter-path or terminal in-band failure: + 'step/end' + agent/request-error(original error, consecutive retry attempt, signal) + retry in the next numbered step or preserve the original error + otherwise: 'assistant/chunk' - agent/step-result - 'assistant/message' (transformed content or empty success anchor after step-result rejection) - each tool call: - 'tool/call' - tools/pre-execute -> monotonic guards -> tools/execute -> tools/post-execute -> tools/result - 'tool/result' - append post-tool context and steering - 'step/end' - agent/turn-continuation - agent/turn-stop (terminal policy) - stop unless tools or continuation policy ask for another step + agent/step-result + 'assistant/message' (transformed content or empty success anchor after step-result rejection) + schedule tool calls by ctx.tools.executionMode: + exclusive -> one-call barrier + parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start + each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute + each model-order result -> ordered tools/post-execute -> 'tool/result' + append accepted tool-batch context after all recorded results, then steering + agent/post-step + 'step/end' + agent/turn-continuation + agent/turn-stop (terminal policy) + stop unless tools or continuation policy ask for another step 'turn/end' checkpoint persistence and notify idle/running status ``` Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn. `dsh-system-prompt` owns the harness identity and default persona, which an agent scope may shadow. The loop supplies `model` and `cwd` ([prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)). -Context accepted during tool execution—including async `agent.inject()` notices and post-tool `additionalContext`—waits for settlement, then follows every recorded result. Successful batches preserve call/result adjacency; interrupted batches drain that context before turn closure. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts. + +`dsh-compact-basic` handles pressure and canonical overflow at these checkpoints; retry requires a balanced surface replacement ([RFC](rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)). ### Failure Boundaries -The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends it with an error reason and reports `agent/error` without killing the driver. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and drains service disposers. +The turn is the containment boundary. Final adapter-path and terminal in-band failures close the step before `agent/request-error`; retry opens a numbered step; otherwise, the provider error survives. Attempts reset on success. + +Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched model tool calls receive synthetic `tool/call` and `ABORTED` result pairs before `turn/end`. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants. @@ -112,7 +126,7 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs drivers inside `ctx.agents.withInitiator()`; private orchestration derives `agent.session`; other identities stay explicit ([RFC](rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State @@ -142,7 +156,7 @@ Some seams bend the template deliberately: LLM combines interface and consumer b ### Bundles And Apps -`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` adds a terminal front door; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` bundles the default spine ([README](../packages/examples/agent-spine-demo/README.md)). `dsh-stdio-demo` selects `dsh-tui` for interactive terminals and line-oriented `dsh-stdio` for pipes; `dsh-cli-demo` runs one persisted headless turn with format-pure stdout; `dsh-acp-demo` adds stdout-pure ACP over JSON-RPC ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK supplies its default only without an explicit config channel and drives `dsh-jsonrpc` over line-delimited JSON-RPC ([Python SDK](../python/README.md)). Deployments remain thin leaves with swappable backends and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes @@ -156,7 +170,7 @@ New behavior should attach to a documented extension point; changing the shipped | Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it | | Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events | | Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning | -| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall; use serial `agent/turn-stop` for a monotonic terminal stop | +| Intercept prompts, requests, model completion/failure, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` event; use serial `agent/turn-stop` for a monotonic terminal stop | | Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1aafbb2817..58641456b1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -19,6 +19,7 @@ flowchart LR pkg_session["session"] svc_sessions["ctx.sessions
In-memory session store"] pkg_agent["agent"] + pkg_cli_demo["cli-demo"] pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] pkg_subagent_inprocess["subagent-inprocess"] @@ -48,7 +49,7 @@ flowchart LR pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] - svc_agents["ctx.agents
Agent registry"] + svc_agents["ctx.agents
Agent service"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] @@ -77,7 +78,6 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_subagent_acp["subagent-acp"] - pkg_subagent_mock["subagent-mock"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tool_tasks["tool-tasks"] @@ -129,7 +129,6 @@ flowchart LR pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents - pkg_subagent_mock --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks @@ -147,6 +146,7 @@ flowchart LR svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop + svc_agents --> pkg_cli_demo svc_agents --> pkg_invariants svc_agents --> pkg_stdio_demo svc_agents --> pkg_subagent_inprocess @@ -170,6 +170,7 @@ flowchart LR svc_sessionPersistence --> pkg_tool_bash svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop + svc_sessions --> pkg_cli_demo svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query @@ -207,14 +208,14 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | @@ -223,8 +224,8 @@ flowchart LR | `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | -| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | +| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; a model-facing compact tool remains deferred. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7cccb69e0a..ad8ffceede 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` +Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` · `llm` · `systemPrompt` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -20,14 +20,14 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** Runtime-only transport override for tests; production uses stdio. */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } ``` Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:208`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` @@ -46,6 +46,8 @@ export interface Config { provider: string /** Model name for ACP-created agents (must have a registered adapter). */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -69,19 +71,26 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog -/** Plugin configuration for declarative startup agents. */ +/** Agent-loop plugin configuration. */ export interface Config { + /** + * Maximum parallel-safe calls in flight per agent step. `1` is serial; + * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -90,9 +99,9 @@ export interface Config { } ``` -Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) +Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -115,6 +124,8 @@ Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loo export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** Agent-loop concurrency cap; `1` is serial. */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -195,6 +206,42 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core- Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts) +## `@deepseek-ai/dsh-cli-demo` + +```ts config-catalog +/** App config forwarded to the spine, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the configured agent. */ + provider: string + /** Model name for the configured agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-spine-demo. */ + toolTasks?: NonNullable + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} +``` + +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) + +Source: [`packages/examples/cli-demo/src/index.ts:22`](../packages/examples/cli-demo/src/index.ts) + ## `@deepseek-ai/dsh-code-runtime-worker` ```ts config-catalog @@ -250,7 +297,9 @@ export interface BasicCompactConfig { maxTokens?: number /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ compactionRetries?: number - /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ + /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ + maxOverflowRetries?: number + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } ``` @@ -385,7 +434,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:33`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -588,7 +637,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:26`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -645,8 +694,12 @@ Requires: `sessions` export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** @@ -669,7 +722,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:55`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` @@ -758,12 +811,12 @@ Requires: `agents` · `userInteraction` export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string + /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ + sessionId?: string } ``` -Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) +Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts) ## `@deepseek-ai/dsh-stdio-demo` @@ -776,13 +829,15 @@ Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) * is the explicit model-facing tool order (forwarded to the system-prompt plugin); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner. + * `welcome` is the UI banner and `ui` configures terminal mode/presentation. */ export interface Config { /** Provider route for the `main` agent. */ provider: string /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -795,6 +850,8 @@ export interface Config { persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ welcome?: string + /** Terminal front-door selection and pi-tui presentation settings. */ + ui?: UiConfig /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -802,7 +859,7 @@ export interface Config { /** Generic background-task control-tool config forwarded through agent-core. */ toolTasks?: NonNullable /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -810,11 +867,22 @@ export interface Config { /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } + +/** App-level terminal selection with nested TUI presentation settings. */ +export interface UiConfig { + /** Select a concrete front door or infer it from the process streams. */ + mode?: TerminalMode + /** Settings forwarded only when the pi-tui front door is selected. */ + tui?: uiTui.TuiConfig +} + +/** Terminal front door selected by the app bundle. */ +export type TerminalMode = 'auto' | 'readline' | 'tui' ``` -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:37`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:75`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -877,41 +945,6 @@ export interface Config { Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) -## `@deepseek-ai/dsh-subagent-mock` - -Requires: `subagents` - -```ts config-catalog -/** Config for the mock provider; all optional with test-friendly defaults. */ -export interface Config { - /** Registry name to register under. */ - name: string - /** The text the scripted child "returns" as its final answer. */ - reply?: string - /** The stop reason the run settles with. */ - stopReason?: SubagentStopReason - /** Which start-time capabilities to advertise (default: all `true`). */ - capabilities?: Partial - /** - * The conversation-history descriptor to declare - * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh - * conversation). Set `true` to exercise seeded/fork wording in consumer - * tests. This flag says nothing about tool, service, scope, or authority - * inheritance. - */ - inheritsParentContext?: boolean - /** - * Structured value surfaced when a request carries an `outputSchema` and the - * `outputSchema` capability is on (default: `{ reply }`). - */ - structured?: unknown -} -``` - -Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) - -Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) - ## `@deepseek-ai/dsh-subagent-spawn` Requires: `subagents` @@ -961,7 +994,7 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-token-meter` @@ -1113,7 +1146,7 @@ export interface Config { } ``` -Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) +Depends on: [`AgentOptions`](core-data-structures/core.md) Source: [`packages/subagent/tool-subagent/src/index.ts:23`](../packages/subagent/tool-subagent/src/index.ts) @@ -1191,7 +1224,43 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:322`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:382`](../packages/core/tools/src/index.ts) + +## `@deepseek-ai/dsh-tui` + +Requires: `agents` · `userInteraction` · `tools` + +```ts config-catalog +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Header subtitle. Defaults to `ready.`. */ + welcome?: string + /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ + sessionId?: string +} + +/** Presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-output lines shown before the card is collapsed. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question dialog. */ + maxQuestionOptions?: number + /** User-question dialog width in terminal columns. */ + questionDialogWidth?: number + /** User-question dialog maximum height in terminal rows. */ + questionDialogMaxHeight?: number + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Terminal window title while the UI is mounted. */ + title?: string +} +``` + +Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 27c31ba1ef..854acc2750 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-a-package.md: 2930cee9ab64b382f6211335ae639bce45629d1d -adding-a-package.zh.md: 10e906c320203c3658c103fc8936540f72697d65 +adding-a-package.md: 404492a9903d823feb011ec2536e4b66ef110e32 +adding-a-package.zh.md: 4be67137d416f373bf3477e30785055fedc5ac6f diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 2930cee9ab..404492a990 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -16,7 +16,7 @@ packages/// src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes, - # + gated Model Experience context blocks or short sentence + # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) ``` @@ -51,24 +51,32 @@ Keep package-specific service API, config, events, extension points, and design ### Request surface and condition -**What the model sees**: An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +#### What the model sees -**Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. +An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. -#### Verbatim text for this context surface, when needed +##### Verbatim text for this field, when needed ```markdown Stable system-prompt prose of any length, or another long non-generated literal, copied exactly from source. ``` +#### Token effect + +Fixed, conditional, retained, replaced, capped, or zero-direct token effect. + +#### KV Cache effect + +Append-only, prefix-stable, replacing, or independent behavior, including the exact conditions that may invalidate reuse. + ## Known Limitations and Deferred Work - **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the two fields shown above. Quote stable text owned by the package: system-prompt prose goes in a titled H4 plus `markdown` fence, other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the three ordered H4 fields shown above and one prose paragraph under each. Quote stable text owned by the package: system-prompt prose goes in a titled H5 plus `markdown` fence under the field that introduces it—normally `What the model sees`—other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. In `KV Cache effect`, distinguish append-only growth, a stable repeated prefix, replacement of earlier request tokens, and an independent model request, then name the package-owned changes that can invalidate reuse. “Does not invalidate” means the package preserves an already-reusable prefix; provider cache availability and eviction remain outside the package contract. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. -A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts); a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. +A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a `KV Cache effect` H4 and one non-empty paragraph; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. ## 5. Verify diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 10e906c320..4be67137d4 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -16,7 +16,7 @@ packages/// src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes, - # + gated Model Experience context blocks or short sentence + # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) ``` @@ -51,24 +51,32 @@ package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-c ### Request surface and condition -**What the model sees**: An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. +#### What the model sees -**Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. +An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. -#### Verbatim text for this context surface, when needed +##### Verbatim text for this field, when needed ```markdown Stable system-prompt prose of any length, or another long non-generated literal, copied exactly from source. ``` +#### Token effect + +Fixed, conditional, retained, replaced, capped, or zero-direct token effect. + +#### KV Cache effect + +Append-only, prefix-stable, replacing, or independent behavior, including the exact conditions that may invalidate reuse. + ## Known Limitations and Deferred Work - **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. ```` -根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述两个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 +根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述三个有序 H4 字段,每个字段下有一个正文段落。引用包拥有的稳定文本:系统提示词放在引出它的字段下,用带标题的 H5 加 `markdown` 围栏表示,通常归入 `What the model sees`;其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。填写 `KV Cache effect` 时,应区分仅追加增长、稳定重复的前缀、替换既有请求 token 和独立模型请求,并列出会使缓存复用失效、且由本包拥有的变化。“不使缓存失效”仅表示本包保留了已有的可复用前缀;缓存是否可用以及何时淘汰不属于本包契约。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。 -没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 +没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。 ## 5. 验证 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 207ea114e0..313cb8489b 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796 -extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44 +extension-cookbook.md: c6bf6ddd4bf0da8bd7377ab57e779750b72bd25e +extension-cookbook.zh.md: dcc188b7d3ac44f5d86253c20147e95da0bea648 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3474bc116b..c6bf6ddd4b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -40,7 +40,7 @@ A UI plugin renders from the `session/event` feed (the assistant token stream as ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -54,7 +54,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. +Six runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/repl-agent`](../../examples/repl-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/headless-agent`](../../examples/headless-agent) (the same capability class behind a one-shot task and DSH-native output, `pnpm run demo:headless -- "task"`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the headless leaf loads [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and all three app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map @@ -102,7 +102,7 @@ Every product feature maps to a listener on a documented extension seam — the | `/loop` | on the `turn/end` session event, `send()` the next iteration; or force-continue | | Dynamic workflow | `ctx.workflows` + the worker-thread engine + the `workflow` tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final `tools/result` commit (including enclosing `run_code`), and terminal `agent/turn-stop` | | Queued + steering messages | core `Agent.send()` / `Agent.steer()` | -| Context compaction (auto + manual) | the `ctx.compact` seam + a backend (`dsh-compact-basic`) on the serial `agent/pre-step` seam; auto = token-pressure check before each step; a manual trigger invokes the same `ctx.compact` routine ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | +| Context compaction (auto + manual) | the `ctx.compact` seam + `dsh-compact-basic`; automatic pressure runs on serial `agent/post-step`, canonical overflow recovery runs on `agent/request-error`, and manual callers use the same compact service ([compaction RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) — the model-facing `/compact` consumer tool is deferred) | | System prompt configurability | `ctx.systemPrompt.section()` with ordering and scope-local shadowing | | AGENTS.md (root) | a section provider reading the file | | AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener | diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1a605b20fe..dcc188b7d3 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -40,7 +40,7 @@ UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/ch ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -54,7 +54,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 +六个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/repl-agent`](../../examples/repl-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/headless-agent`](../../examples/headless-agent)(同类能力通过单次任务和 DSH 原生输出运行,`pnpm run demo:headless -- "task"`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),headless 叶子加载 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),三个 app 包都通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 @@ -102,7 +102,7 @@ export function apply(ctx: Context) { | `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 | | 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 | | 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` | -| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + 串行 `agent/pre-step` seam 上的后端(`dsh-compact-basic`);自动 = 每步之前的 token 压力检查;手动触发调用同一个 `ctx.compact` 例程([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | +| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + `dsh-compact-basic`;自动压力检查运行在串行 `agent/post-step`,规范化溢出恢复运行在 `agent/request-error`,手动调用方使用同一个压缩服务([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) | | 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 | | AGENTS.md(根目录) | 一个读取该文件的 section provider | | AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` | diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6fbffc1798..7d7472a33e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -3,9 +3,9 @@ # Cordis Events Catalog -Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration's JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. +Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. @@ -18,156 +18,355 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog +/** + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. + * @param agent - the newly registered agent with its live session and completed setup. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/created'(this: Scoped, agent: Agent): void ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. ```ts cordis-catalog +/** + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. + * @param agent - the exact agent removed from the registry. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/disposed'(this: Scoped, agent: Agent): void ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. ```ts cordis-catalog +/** + * A step or turn errored. The loop reports a failure here (plus the logger) + * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:311`](../../packages/core/agent/src/types.ts) + +### `agent/post-step` — serial + +Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`. A cancelled tool batch reaches this checkpoint with an aborted signal. + +```ts cordis-catalog +/** + * Awaited serial checkpoint after the response, real or synthetic tool + * results, injected context, and steering are durable but before `step/end`. + * A cancelled tool batch reaches this checkpoint with an aborted signal. + * @param agent - the agent whose step is settling. + * @param turn - the open turn number. + * @param step - the open step number. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ +'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void +``` + +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial -Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history. `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog -'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void +/** + * Awaited serial checkpoint before `step/start`; appends land outside the + * pending step and are included when the loop derives request history. + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param signal - the turn abort signal. + * @mode serial + */ +'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:217`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog +/** + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog +/** + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. + * @param agent - the agent whose inbox received the message. + * @param content - the accepted content blocks retained by the inbox. + * @param info - the accepted source plus whether it entered as steering. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog +/** + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param config - the config the loop would use (frozen); return a replacement to switch. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) + +### `agent/request-error` — waterfall + +Recover a model-request failure after its failed step has closed. `retry` opens a new numbered step; `fail` preserves the original request error. Call `next()` to delegate to the next recovery listener or the default. + +```ts cordis-catalog +/** + * Recover a model-request failure after its failed step has closed. `retry` + * opens a new numbered step; `fail` preserves the original request error. + * Call `next()` to delegate to the next recovery listener or the default. + * @param agent - the agent whose request failed. + * @param turn - the open turn number. + * @param step - the failed step number. + * @param error - the original model-request failure. + * @param retryAttempt - zero-based number of prior recovery retries. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ +'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise +``` + +Types: [Agent](../core-data-structures/core.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall -Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. +Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request. + * Changing context belongs in history; contributors should prepend to + * `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent whose session prefix is being composed. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. + * @mode waterfall + */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts. ```ts cordis-catalog +/** + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void ``` -Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog +/** + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:172`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). ```ts cordis-catalog +/** + * Waterfall: post-process the assembled assistant {@link Message} before + * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog +/** + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [ContinuationDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. ```ts cordis-catalog +/** + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. + * @param agent - the agent whose composed continuation outcome may be stopped. + * @param turn - the turn at its terminal-stop checkpoint. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [ContinuationStop](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) + +## `agent-loop/*` + +### `agent-loop/config-start-failed` — emit + +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. + +```ts cordis-catalog +/** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ +'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +``` + +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -176,10 +375,17 @@ Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/t Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @mode waterfall + */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise ``` -Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) +Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [ApprovalService](../core-data-structures/approval.md) · [Scoped](../core-data-structures/scope.md) Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) @@ -190,6 +396,13 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` @@ -202,6 +415,14 @@ Source: [`packages/fs/fs/src/index.ts:61`](../../packages/fs/fs/src/index.ts) Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog +/** + * Record a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. + * @mode emit + */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void ``` @@ -214,6 +435,14 @@ Source: [`packages/fs/fs/src/index.ts:70`](../../packages/fs/fs/src/index.ts) Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` @@ -228,12 +457,23 @@ Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. ```ts cordis-catalog +/** + * Waterfall around every streaming model call (retry, replay, routing). + * Bound to the {@link LlmService}; call `next()` to reach the resolved + * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request. A LOOP-built request arrives + * deep-frozen (mutation throws): its content is a pure function of the + * session log (the reconstructability RFC), so listeners read it, never + * rewrite it. A hand-built one-shot (compaction summarize) is the + * caller's own object and stays mutable here. + * @mode waterfall + */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:40`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) ## `session/*` @@ -242,42 +482,88 @@ Source: [`packages/llm/llm/src/index.ts:40`](../../packages/llm/llm/src/index.ts Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog +/** + * Creation announcement during session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only sessions entered through that agent's context. + * @param session - the session just entered and announced. + * @dshScopeScan unsupported + * @mode emit + */ 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) +Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) + +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog +/** + * Emitted once when an announced session leaves the store, including + * publication rollback, but never for an entry whose creation announcement + * did not begin. Listener failures are logged and contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. + * @param session - the session that is no longer live in the store. + * @dshScopeScan unsupported + * @mode emit + */ 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts) +Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) + +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ### `session/event` — emit Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog +/** + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only events from sessions entered through that agent's context. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. + * @dshScopeScan unsupported + * @mode emit + */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void ``` -Types: [SessionEvent](../core-data-structures/core.md) +Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog +/** + * Awaited parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through + * {@link SessionStore.flush}. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. + * @param session - the session whose buffered events must reach durable storage. + * @dshScopeScan unsupported + * @mode parallel + */ 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) +Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) + +Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) ## `subagent/*` @@ -286,40 +572,74 @@ Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/sr A ready child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog +/** + * A ready child settled. Scope-filtered dispatch uses the same delegating + * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * same scoped audience. + * @param info - the run identity and terminal outcome. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:108`](../../packages/subagent/subagent/src/index.ts) +Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) + +Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit A provider became resolvable in the registry. ```ts cordis-catalog +/** + * A provider became resolvable in the registry. + * @param provider - the registered provider. + * @mode emit + */ 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) +Types: [SubagentProvider](../core-data-structures/subagent.md) + +Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit A provider left the registry. Accepted runs remain holder-owned. ```ts cordis-catalog +/** + * A provider left the registry. Accepted runs remain holder-owned. + * @param name - the provider name that no longer resolves. + * @mode emit + */ 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:88`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit A provider established a ready child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. ```ts cordis-catalog +/** + * A provider established a ready child. For in-process providers, + * `ctx.agents.get(info.id)` resolves during this notification. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * parent-scoped listener observes only its own delegations. Paired with + * `subagent/end`. + * @param info - the provider and ready child identity. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts) +Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) + +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` @@ -328,9 +648,19 @@ Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/s Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. ```ts cordis-catalog +/** + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. + * @mode waterfall + */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` +Types: [AssembleContext](../core-data-structures/system-prompt.md) · [Scoped](../core-data-structures/scope.md) · [SystemPrompt](../core-data-structures/system-prompt.md) + Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit @@ -338,6 +668,11 @@ Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/syst Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope. ```ts cordis-catalog +/** + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. + * @mode emit + */ 'system-prompt/change'(): void ``` @@ -350,6 +685,15 @@ Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/syst A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. ```ts cordis-catalog +/** + * A tool was registered or unregistered, or a scoped restriction changed + * (the available tool set changed — possibly for one scope only). An + * UNFILTERED registry-subject notification, deliberately not scope-filtered + * dispatch: a global change concerns every agent's next assembly, so a + * scoped listener subscribing here sees every change, not just its own + * scope's. + * @mode emit + */ 'tools/change'(): void ``` @@ -360,10 +704,18 @@ Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/i Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` -Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) +Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts) @@ -372,10 +724,18 @@ Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/in Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` -Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) +Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts) @@ -384,10 +744,17 @@ Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/in Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` -Types: [ToolExecution](../core-data-structures/tools.md) +Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) @@ -396,10 +763,17 @@ Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/in Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. ```ts cordis-catalog +/** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` -Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) +Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) @@ -410,9 +784,21 @@ Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/i One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`. ```ts cordis-catalog +/** + * One `agent()` call settled (clean result, child failure, or run + * cancellation). Paired with {@link Events['workflow/agent-start']} by + * `agent.seq`, exactly once per started call on every stop path — on an + * engine termination path (a worker killed past its grace) the end is + * engine-synthesized with outcome `'cancelled'`. + * @param info - the run's identity snapshot. + * @param agent - the call identity plus its outcome. + * @mode emit + */ 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit @@ -420,9 +806,20 @@ Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/w One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never receives a ready run from the provider emits neither event in this pair. ```ts cordis-catalog +/** + * One `agent()` call established a ready child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never + * receives a ready run from the provider emits neither + * event in this pair. + * @param info - the run's identity snapshot. + * @param agent - the call's sequence number, label, phase, and child id. + * @mode emit + */ 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -430,9 +827,20 @@ Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/w A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start']. ```ts cordis-catalog +/** + * A workflow run settled (any stop reason). Fired when + * {@link WorkflowRun.result} resolves. Paired with + * {@link Events['workflow/start']}. + * @param info - the run's identity snapshot. + * @param result - the outcome data (stop reason, error, agent count) — + * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + * @mode emit + */ 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit @@ -440,9 +848,17 @@ Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/w The script emitted a narration line (a `log(message)` call). ```ts cordis-catalog +/** + * The script emitted a narration line (a `log(message)` call). + * @param info - the run's identity snapshot. + * @param message - the logged message, verbatim. + * @mode emit + */ 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) ### `workflow/phase` — emit @@ -450,9 +866,18 @@ Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/w The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics. ```ts cordis-catalog +/** + * The script entered a phase (a `phase(title)` call) — progress grouping + * for observers; no execution semantics. + * @param info - the run's identity snapshot. + * @param title - the phase title, verbatim. + * @mode emit + */ 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) ### `workflow/start` — emit @@ -460,9 +885,17 @@ Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/w A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end']. ```ts cordis-catalog +/** + * A workflow run started — the script's meta block validated, the body + * about to execute. Paired with {@link Events['workflow/end']}. + * @param info - the run's identity snapshot (id + meta). + * @mode emit + */ 'workflow/start'(info: WorkflowRunInfo): void ``` +Types: [WorkflowRunInfo](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 30d47d70a2..10cd0bbf05 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -3,48 +3,244 @@ # Cordis Services Catalog -Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. ## `ctx.agentLoop` — `AgentLoop` -Concrete ReactLoopAgent factory and driver service. +Concrete agent factory and driver service. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +/** + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published running agent. + */ +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent + +/** + * Create an owned agent on a caller-supplied session id. + * @param ownerCtx - caller context that structurally owns the transaction. + * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @returns the published handle. + */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise + +/** + * Resume an owned agent from the configured persistence service. + * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. + * @param options - persisted identity, loop options, setup, and cancellation. + * @returns the published handle. + */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:335`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) · [AgentOptions](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` -Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. +Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. + +Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. ```ts cordis-catalog +/** + * Read the Agent that initiated the inherited asynchronous driver chain. + * Use this optional form for logging, tracing, metrics, or host attribution + * that also supports agentless calls. When a parent creates a child, setup + * reports the causal parent while `agentCtx.agent` identifies the child. + * @returns the inherited Agent, or `undefined` outside an initiator boundary + * and inside an explicit clearing boundary. + * @throws when this service instance has been disposed. + */ +currentInitiator(): Agent | undefined + +/** + * Read the initiating Agent and fail when no initiator boundary is active. + * Use this for private helpers contractually below a driver, or for a + * deployment-owned outbound request whose contract forbids agentless calls. + * Generic or direct-call seams use optional lookup or explicit request fields. + * @returns the inherited Agent. + * @throws when no initiator is active or this service instance has been disposed. + */ +requireInitiator(): Agent + +/** + * Run an operation with one exact Agent as its process-local initiator. The + * exact synchronous value or Promise returned by the operation is preserved. + * Custom drivers and test harnesses wrap their complete returned foreground + * lifetime. + * A queue or wire receiver may establish this boundary only after validating + * explicit identity and resolving the exact live Agent; this method does neither. + * Detached work remains owned by the subsystem that starts it. + * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization. + * @param operation - synchronous or asynchronous operation to invoke. + * @returns the exact value returned by `operation`. + * @throws when the initiator scope is closing/disposed, or when `operation` throws. + */ +withInitiator(agent: Agent, operation: () => T): T + +/** + * Run an operation inside a boundary that hides any inherited initiating + * Agent. The exact synchronous value or Promise is preserved. + * Use this while creating lazy shared timers, queue pumps, pool maintenance, + * watchers, or exporters so they do not inherit the first Agent that happens + * to initialize them. It clears only initiator attribution, not explicit + * fields, and does not own or drain detached resources. + * @param operation - synchronous or asynchronous operation to invoke without an initiator. + * @returns the exact value returned by `operation`. + * @throws when the initiator scope is closing/disposed, or when `operation` throws. + */ +withoutInitiator(operation: () => T): T + +/** + * Register the agent-creation factory (the loop calls this on construction, + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @returns the disposer that clears the factory slot. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. + */ setFactory(factory: AgentFactory): () => void + +/** + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async create(options: CreateAgentOptions): Promise + +/** + * Load a persisted session and resume an agent on it through the registered + * factory. Rejects if no factory is registered; the factory rejects if + * session persistence is not configured or persistence/setup fails. + * @param options - persisted identity, configuration, and optional setup. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async resume(options: ResumeAgentOptions): Promise + +/** + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * returns undefined without awaiting an in-flight teardown). Exact + * identity is load-bearing: a composite (generator) effect that owns a + * teardown ORDER — the agent factory's lifecycle chain — must yield THIS + * function so Cordis nests the unregistration at that yield position; + * yielding a wrapper would leave it disposing as a concurrent sibling on + * owner unload, unregistering the agent (and emitting `agent/disposed`) + * while its final turn is still draining. + */ register(agent: Agent): () => void -enter(agent: Agent): () => void + +/** + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. + * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @returns an idempotent closure that removes this exact entry and emits + * `agent/disposed` with listener failures contained. When called from a + * synchronous `agent/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + */ +enter(agent: Agent, owner: Agent | undefined): () => void + +/** + * Announce an agent previously inserted with {@link enter}. + * @param agent - the live inserted agent to announce. + * @throws if `agent` is not the exact live registry entry for its id, or its + * creation announcement already began (including a reentrant call from a + * creation listener). + */ announce(agent: Agent): void -get(id: AgentId): Agent | undefined + +/** + * Look up a live agent. + * @param id - the shared agent/session id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ +get(id: SessionId): Agent | undefined + +/** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ +isOwnedBy(id: SessionId, owner: Agent): boolean + +/** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] + +/** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ +roots(): Agent[] ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices. ```ts cordis-catalog +/** + * Ask the composed answerers to decide one readonly same-process request. + * The service borrows the request, agent, session, and live signal directly. + * The request requires an open turn because the audit pair must be enclosed + * by the durable log's commit/replay boundary; an idle ask rejects before + * appending anything. The answerer phase always produces an outcome: an + * aborted signal yields `'cancelled'`, a missing or throwing answerer yields + * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is + * normalized to `'unavailable'`. A failure that prevents either audit append + * from committing still rejects because returning an unlogged decision would + * violate the pair. Session contains post-commit observer failures, so an + * authoritative append cannot reject the request or suppress its matching + * audit event. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @returns the closed outcome; `'allowed-once'` is the only grant. + * @throws when no turn is open or either audit event fails before the session + * append commit point. + */ async request(req: ApprovalRequest): Promise ``` @@ -64,12 +260,31 @@ Implementations must honor these semantics: - Disposal kills all running background processes and awaits their exit. ```ts cordis-catalog +/** + * Apply implementation-owned defaults and caps to a request before execution. + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. + */ abstract resolve(request: BashExecRequest): BashExecSpec + +/** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise + +/** + * Start a background process and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live process handle (reads, kill, quiescence promise). + */ abstract start(spec: BashExecSpec): BashProcess ``` -Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) +Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/index.ts) @@ -78,12 +293,29 @@ Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/inde Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog +/** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ register(contributor: BashEnvContributor): () => void + +/** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ collect(execution: ToolExecution): DshEnvironment + +/** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ list(): BashEnvVariableInfo[] ``` -Types: [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-bash/src/index.ts) @@ -92,6 +324,15 @@ Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-ba Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Execute one program against the request's bindings and capture what it + * emitted. See the class doc for the resolution contract (error is a result + * field; rejection means seam misuse only). + * @param request - the program, its bindings, and the abort signal; the + * request carries everything the runtime acts on, with no hidden defaults. + * @returns the run's outcome: completion value (when transferable), the + * ordered log capture, and the failure (if any). + */ abstract run(request: CodeRunRequest): Promise ``` @@ -104,30 +345,137 @@ Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/co Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog -abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +/** + * Consider automatic compaction for one explicit trigger. Pressure policy + * uses the latest durable routed request, while context-overflow policy may + * force a useful balanced reduction even below the normal threshold. Return + * `null` when no safe range can be compacted. A single oversized retained + * unit or request envelope cannot be repaired through surface compaction. + * + * @param agent - agent context owning the session surface and routing options. + * @param trigger - normal pressure or provider-confirmed context overflow. + * @param signal - cancellation signal; model-backed implementations must forward it. + * @returns the compaction result, or `null` if no compaction was needed. + */ +abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise + +/** + * Forcibly compact a range of surface nodes into a single summary node. + * `start` and `end` name an inclusive span by surface position, not numeric seq + * order; replacements can make visible seqs non-monotonic. Both edges must be + * balanced so assistant tool calls remain paired with their results. A model- + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. + * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} + * for the edge checks. + * + * @param start - first surface seq, inclusive. + * @param end - last surface seq, inclusive. + * @param agent - context whose session is mutated and whose routing options guide summarization. + * @param signal - optional cancellation; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. + */ +abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Types: [Message](../core-data-structures/core.md) +Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:38`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:40`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```ts cordis-catalog +/** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a + * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence + * async even though the local backend only normalizes + realpaths. + * + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - optional cwd override and cancellation signal. + * @returns the stable target; the same file yields the same `targetKey`. + */ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise + +/** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise + +/** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Stream the whole regular text file as decoded text chunks (same text + * semantics as {@link readText}, for large files). The backend owns + * cross-chunk UTF-8 decoding and binary rejection so the policy layer never + * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. + */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + +/** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. + */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. + */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise + +/** + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. + */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` -Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) +Types: [FsDirEntry](../core-data-structures/filesystem.md) · [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsPathInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) @@ -136,28 +484,90 @@ Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. ```ts cordis-catalog +/** + * Register an adapter for the given provider routes. Throws `LlmError` with code + * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing). + * Disposed with the fiber. + * @param providers - every provider route this adapter should serve. + * @param adapter - the adapter that streams calls for those providers. + * @returns the disposer that unregisters all of them. + */ registerAdapter(providers: string[], adapter: LlmAdapter): () => void + +/** + * Describe provider routes with a registered adapter. + * @returns detached provider metadata in registration order. + */ listProviders(): LlmProviderInfo[] + +/** + * Discover models advertised by one registered provider. Catalog membership + * is advisory and never changes routing or request validation. + * @param provider - registered provider route to inspect. + * @returns detached model metadata in adapter-preferred order. + */ async listModels(provider: string): Promise + +/** + * Stream one model call as raw chunks (token-level deltas). Throws + * `LlmError` with code `NO_ADAPTER` if no adapter is registered for + * `options.provider`. Replay state is retained only when the same adapter + * instance owns its historical provider and the target provider. Final + * adapter selection, dispatch, and iteration failures retain their original + * Error identity and are tagged in a call-local scope for narrow agent-loop + * request recovery; middleware and nested-call failures remain untagged for + * the outer call. + * @param options - the full request; `options.provider` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. + */ stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:96`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error. ```ts cordis-catalog +/** + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. + * @returns the effective preset name, or `custom` when nothing matches. + */ current(events: readonly SessionEvent[]): string + +/** + * Resolve a preset's knob bundle. + * @param name - the preset name to resolve. + * @returns the configured bundle. + * @throws when `name` is not in the table. + */ resolve(name: string): PresetSpec + +/** + * Build the client option for a table entry or {@link CUSTOM_PRESET}. A + * missing label falls back to the table key. + * @param name - a table key, or `custom`. + * @returns the option a client renders. + * @throws when `name` is neither a table key nor `custom`. + */ optionOf(name: string): PresetOption + +/** + * Record a changed preset, then update each changed knob through its own + * setter. Selecting the effective preset again appends nothing. + * @param session - the session the switch belongs to. + * @param name - the preset to switch to; unknown names throw. + */ set(session: Session, name: string): void ``` -Types: [SessionEvent](../core-data-structures/core.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/src/index.ts) @@ -166,6 +576,17 @@ Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/ Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. ```ts cordis-catalog +/** + * Wrap `argv` so it executes confined under `policy` on this host; the + * caller spawns the returned argv in place of its own. + * @param argv - the exact argv the caller is about to spawn (program plus + * arguments), NOT a shell string — a shell-shaped consumer passes + * `['bash', '-c', command]`. + * @param policy - the file-effect policy this execution runs under, + * carried per call (see {@link SandboxPolicy}). + * @returns the argv to spawn instead, plus the enforcement completeness + * the selected backend achieves for it. + */ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` @@ -178,14 +599,53 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog +/** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ abstract locate(meta: SessionHeader): SessionLocation | undefined + +/** + * Register a new session's metadata. A backend MAY defer the physical write + * until the first {@link append} (lazy materialization), in which case a + * created-but-never-appended session is absent from {@link list} + * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. + */ abstract create(meta: SessionHeader): Promise + +/** + * Durably persist a batch of events (called from the write-behind drain at + * the `session/flush` checkpoint). Honors the append-only and contiguous-seq + * contracts: the first event's `seq` MUST equal the stored next-seq (after + * `load` has durably closed any interrupted turn). Rejects non-JSON- + * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. + */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise + +/** + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. + * @param id - the persisted session to reload. + * @returns the header and a log ending on a balanced `turn/end`. + */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise ``` -Types: [SessionEvent](../core-data-structures/core.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../../packages/session-persistence/session-persistence/src/index.ts) @@ -194,13 +654,45 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../ Live-preferred logical-corpus exact-read and relationship-tracing service. ```ts cordis-catalog +/** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ listSessions(): Promise + +/** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ async listEvents(sessionId: SessionId): Promise + +/** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. + */ async traceSession(sessionId: SessionId): Promise + +/** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. + */ async traceEvent(request: SessionEventTraceRequest): Promise + +/** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ async readEvent(request: SessionEventReadRequest): Promise ``` +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) + Source: [`packages/session-query/session-query/src/index.ts:38`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -210,29 +702,172 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog +/** + * Create a session owned by the calling fiber: disposing that fiber stops + * event notification and removes the session from the store. `options.seed` + * populates the session with a copy of those events (replay/fork); + * `options.meta` attaches creation metadata (validated absolute `cwd`, + * `parentSession` lineage) as the immutable {@link SessionHeader} (the store + * fills `version`/`id`/`createdAt`). + * + * For an agent whose session must be torn down IN ORDER with its loop (so the + * loop's final flush is captured before the store attachment ends), do NOT use this + * — fold the session lifecycle into the agent's own effect via + * {@link prepare} + {@link enter} + {@link announce} (see + * `dsh-agent-loop`'s creation transaction). + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path (storage backends key directories off it). + */ create(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Build a session WITHOUT entering it into the store — validate the id/cwd and + * construct the {@link Session} (with its immutable {@link SessionHeader}). + * Pairs with {@link enter} + {@link announce}: a caller that owns a composite + * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE + * effect so a fiber unload tears the session + agent down as a single ORDERED + * chain rather than as racing sibling effects — which would remove the publication hooks + * before the loop's closing `session/flush`, dropping the closing events. + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path. + */ prepare(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Enter a {@link prepare}d session into the store: install the module-private + * append publication hooks and add it to the store. Returns the DETACH + * disposer (hooks + store removal). Does NOT emit `session/created` — + * the caller yields this disposer inside its effect and THEN calls + * {@link announce}, so a throwing `session/created` listener rolls the attach + * back instead of leaking it. + * + * Re-checks the id for a duplicate: `prepare` and `enter` are public + * cross-package primitives and a caller may interleave arbitrary work (or + * another create) between them, so a stale prepared session must NOT overwrite + * a live store entry of the same id — its detach disposer would later delete + * the REAL session. The {@link create} convenience and the agent factory call + * the two back-to-back so they never trip this, but the public seam cannot + * assume that. + * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (publication hooks + store removal). When called from + * a synchronous `session/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + * @throws if a session with this id is already in the store. + */ enter(session: Session): () => void + +/** Emit `session/created` exactly once for an {@link enter}ed session (with + * the carrier {@link enter} captured). Separate from {@link enter} so the + * caller can yield the detach disposer first (rollback safety — see + * {@link enter}). + * @param session - the entered session to announce to listeners. + * @throws if the session is not live or its announcement already began, + * including a reentrant call from a creation listener. */ announce(session: Session): void + +/** + * Dispatch the awaited `session/flush` durability checkpoint for `session`, + * with the carrier captured at {@link enter}. THE flush entry point: the + * store owns the carrier, so callers (the loop's turn-end checkpoint, idle + * injection, teardown drains) must come through here rather than dispatch a + * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the + * scoped-dispatch invariant can pin it. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when every flush listener has settled; after all settle, + * rejects with the first registered listener failure if any listener failed. + */ async flush(session: Session): Promise + +/** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined + +/** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] + +/** + * Create a live child session from a turn-enclosed prefix of a live source. + * `boundary` is an inclusive source event seq; omitted means the source's + * current last event. A non-empty selected slice must end at `turn/end`. + * + * @param source - Live source session object or id. + * @param boundary - Inclusive source event seq to fork through; omitted means + * the source's current last event, and omitted on an empty source forks an + * empty child. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. + * @returns The created live child session. + */ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:580`](../../packages/core/session/src/index.ts) +Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. ```ts cordis-catalog +/** + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. + * @param provider - the provider to register by `provider.name`. + * @returns the exact Cordis effect disposer that unregisters this provider; + * composite effects may yield it directly to preserve teardown ordering. + */ registerProvider(provider: SkillProvider): () => void + +/** + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. + * @param skill - the complete skill definition to expose for discovery. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. + */ register(skill: SkillRegistration): () => void + +/** + * List model-invocable skill summaries for a workspace. Lookup options and + * provider candidates are readonly same-process values borrowed throughout + * discovery. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries, excluding skills disabled for model invocation. + */ async list(options: SkillLookupOptions = {}): Promise + +/** + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. + * @param name - kebab-case skill name. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill, including body content, or `undefined`. + */ async get(name: string, options: SkillLookupOptions = {}): Promise ``` +Types: [SkillDefinition](../core-data-structures/skills.md) · [SkillLookupOptions](../core-data-structures/skills.md) · [SkillProvider](../core-data-structures/skills.md) · [SkillRegistration](../core-data-structures/skills.md) · [SkillSummary](../core-data-structures/skills.md) + Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts) ## `ctx.spillStore` — `SpillStore` (abstract seam) @@ -246,9 +881,16 @@ Semantics every implementation must honor: - `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result). ```ts cordis-catalog +/** + * Persist `input.content` to a session-scoped spill artifact. + * @param input - the owner, provenance, suggested name, and full text to save. + * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure. + */ abstract saveText(input: SaveTextSpill): Promise ``` +Types: [SaveTextSpill](../core-data-structures/spill.md) · [SpillRef](../core-data-structures/spill.md) + Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts) ## `ctx.subagents` — `SubagentService` @@ -256,25 +898,90 @@ Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/ Named provider registry and capability-checked start surface. ```ts cordis-catalog +/** + * Register a provider under its name. Registration is effect-scoped and HMR + * safe; removing a provider blocks new starts but does not revoke runs that + * were already returned to their holders. + * @param provider - the trusted provider implementation. + * @returns the exact Cordis effect disposer. + */ registerProvider(provider: SubagentProvider): () => void + +/** + * Look up a provider by name. + * @param name - the provider name. + * @returns the provider, or undefined when absent. + */ getProvider(name: string): SubagentProvider | undefined + +/** + * List registered provider names in insertion order. + * @returns the registered names. + */ list(): string[] + +/** + * Establish a ready child on the named provider. Capability and semantic + * checks run before delegation. Provider ownership lasts until its promise + * fulfills; a rejection therefore has no run for the caller to dispose and + * emits no run lifecycle events. + * @param name - the provider to use. + * @param request - child prompt, parent, signal, and optional capabilities. + * @returns the ready holder-owned run. + */ async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts) +Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) + +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` Registry service for the prompt inputs assembled before each model step. ```ts cordis-catalog +/** + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. + */ section(section: PromptSection): () => void + +/** + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. + * @returns the exact Cordis effect disposer. + */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void + +/** + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. + */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void + +/** + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. + */ async assemble(context: AssembleContext = {}): Promise ``` +Types: [AssembleContext](../core-data-structures/system-prompt.md) · [PromptSection](../core-data-structures/system-prompt.md) · [ToolProviderResult](../core-data-structures/system-prompt.md) + Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tasks` — `TaskService` @@ -282,17 +989,87 @@ Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/sys The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts. ```ts cordis-catalog +/** + * Preflight access, validation, and owner cleanup before starting and + * atomically registering work. A throwing starter leaves nothing registered; + * after it returns, registration cannot fail. Settlement records the outcome, + * notifies listeners, and releases waiters. + * @param spec - task identity, owner, and synchronous starter. + * @returns the registry-issued `-N` id. + */ start(spec: TaskStart): TaskId + +/** + * List caller-owned and unowned tasks in registration order without exposing + * another session's labels. + * @param caller - reading agent; a non-agent caller sees only unowned tasks. + * @returns fresh snapshots. + */ list(caller?: Agent): TaskSnapshot[] + +/** + * Return a non-consuming snapshot without changing its read cursor or notice + * state. Throws for an unknown or foreign task. + * @param id - task to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ get(id: TaskId, caller?: Agent): TaskSnapshot + +/** + * Read the next stream delta, or the idempotent final output after settlement. + * A terminal read marks the task reported. Throws for an unknown or foreign + * task. + * @param id - task to read. + * @param caller - reading agent checked against the owner. + * @returns output text and the post-read snapshot. + */ read(id: TaskId, caller?: Agent): TaskRead + +/** + * Request cancellation, then mark the task stopping and reported. A producer + * throw propagates without changing task state. Throws for an unknown or + * foreign task. + * @param id - task to cancel. + * @param caller - killing agent checked against the owner. + * @param reason - logged reason forwarded to the producer. + * @returns `requested` for live work, otherwise `already-finished`. + */ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' + +/** + * Wait for settlement or timeout without cancelling the task. Caller abort + * rejects only while the task is live; after settlement it returns the + * terminal snapshot so a notice suppressed for this waiter is still delivered. + * Timed-out and aborted waits detach their resolvers. Throws for invalid, + * unknown, or foreign input. + * @param id - task to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param caller - waiting agent checked against the owner. + * @param signal - optional cancellation of the wait itself. + * @returns snapshot at settlement or timeout. + */ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise + +/** + * Register an effect-scoped completion listener. Each listener is contained; + * returned promises are observed but not awaited. No listener runs after + * service disposal. + * @param listener - receives each terminal snapshot and its exact owner. + * @returns disposer that unregisters the listener. + */ onTaskDone(listener: TaskDoneListener): () => void + +/** + * Attach an effect-scoped surface that can read and stop tasks. {@link start} + * refuses work while none is attached. + * @param name - diagnostic label; duplicate names remain independent. + * @returns disposer that detaches this surface. + */ attachSurface(name: string): () => void ``` -Types: [Agent](../core-data-structures/core.md) +Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/index.ts) @@ -301,11 +1078,33 @@ Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Measure current request pressure and surface through the durable tail. + * + * Provider usage is reused only when the latest successful call's canonical + * request envelope matches `requestHeader` and its total is no lower than + * that call's full heuristic anchor; otherwise the complete envelope and + * surface are heuristically repriced. + * + * `requestHeader` affects request pressure only; surface fields always + * describe the current session surface. Every call clones those positional + * nodes, so measurement is O(surface). + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure and surface measurement. + */ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement + +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed service heuristic. + */ estimateMessage(message: Message): number ``` -Types: [Message](../core-data-structures/core.md) +Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) @@ -314,27 +1113,103 @@ Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-m Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. + */ register(definition: ToolDefinition): () => void + +/** + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. + * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). + * @returns the exact disposer that lifts this restriction. + */ restrict(filter: ToolRestriction): () => void + +/** + * Register a monotonic guard after the extensible `tools/pre-execute` + * waterfall. A plain-context guard applies globally; one registered through + * `agent.ctx` applies only to that agent. Any matching guard may deny by + * returning a reason, while no guard can force-allow a call another guard + * denied. The exact effect disposer is returned for ordered ownership and + * HMR cleanup. + * @param guard - synchronous check; a returned string denies the execution. + * @returns the exact disposer that unregisters the guard. + */ guard(guard: ToolGuard): () => void + +/** + * Look up a tool as one scope sees it (scoped + * shadows global; a restricted-away global reads as absent). Presenters pass + * the calling agent so the rendered card matches the definition that + * actually executed. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns the definition the scope resolves, or undefined when none is visible. + */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined + +/** + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns one deep-cloned schema per visible tool. + */ schemas(scope?: ScopeKey): ToolSchema[] + +/** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ +executionMode(exec: ToolExecutionInput): ToolExecutionMode + +/** + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. + * @param exec - the typed same-process call input. The registry assigns its + * correlation token before policy begins. + * @returns the materialized final result. + */ async execute(exec: ToolExecutionInput): Promise ``` -Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) +Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:378`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` `ctx.userInteraction`: one active UI provider plus an `ask()` surface. ```ts cordis-catalog +/** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ registerProvider(provider: UserInteractionProvider): () => void + +/** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Questions, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ async ask(request: AskUserQuestionRequest): Promise ``` +Types: [AskUserQuestionAnswer](../core-data-structures/user-interaction.md) · [AskUserQuestionRequest](../core-data-structures/user-interaction.md) · [UserInteractionProvider](../core-data-structures/user-interaction.md) + Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts) ## `ctx.web` — `WebService` @@ -351,12 +1226,48 @@ Selection semantics (resolved at execution time, never order-dependent): - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. ```ts cordis-catalog +/** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerSearchProvider(provider: WebSearchProvider): () => void + +/** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerFetchProvider(provider: WebFetchProvider): () => void + +/** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. + */ async search(request: WebSearchRequest, signal?: AbortSignal): Promise + +/** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. + */ async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` +Types: [WebFetchProvider](../core-data-structures/web.md) · [WebFetchRequest](../core-data-structures/web.md) · [WebFetchResult](../core-data-structures/web.md) · [WebSearchProvider](../core-data-structures/web.md) · [WebSearchRequest](../core-data-structures/web.md) · [WebSearchResult](../core-data-structures/web.md) + Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts) ## `ctx.workflows` — `WorkflowService` (abstract seam) @@ -364,9 +1275,17 @@ Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles. ```ts cordis-catalog +/** + * Parse and execute a workflow script. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run; its `result` resolves when the script settles. + */ abstract start(request: WorkflowStartRequest): WorkflowRun ``` +Types: [WorkflowRun](../core-data-structures/workflow.md) · [WorkflowStartRequest](../core-data-structures/workflow.md) + Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index c5fa1fe13b..8634415a62 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -6,15 +6,23 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv ## Identity and outcome -Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids. +Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. ```ts type-equiv +/** + * Pairs one `approval/asked` audit event with its `approval/decided`. + * Service-issued (one fresh id per {@link ApprovalService.request} call). + */ type ApprovalRequestId = Branded<'ApprovalRequestId'> ``` `ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate. ```ts type-equiv +/** + * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn + * request, or unavailable answerer. Callers fail closed on `unavailable`. + */ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ``` @@ -23,6 +31,18 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' `ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. ```ts type-equiv +/** + * A session's approval policy — what happens to an {@link ApprovalService} + * ask BEFORE any interactive answerer sees it: + * + * - `'ask'` (the default) — delegate to the composed answerers; with none + * composed the chain falls through to the fail-closed `'unavailable'` + * (exactly today's behavior). + * - `'never'` — never prompt anyone: every ask resolves `'rejected'` + * deterministically. The strict headless stance (CI, unattended runs) and + * the only policy value stated in the system prompt — unlike `'ask'`, its + * outcome is knowable without asking, so stating it cannot overclaim. + */ type ApprovalPolicy = 'ask' | 'never' ``` @@ -33,6 +53,10 @@ The prompt section states the deterministic `never` behavior and records either `ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift. ```ts type-equiv +/** + * Readonly same-process permission question. `callId` links to an already + * presented tool call, so arguments are not duplicated here. + */ interface ApprovalRequest { /** * The agent on whose behalf the question is asked. Routes the question (a diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c61c24add6..060d249eb7 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -9,10 +9,12 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t `DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. ```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` ``` ```ts type-equiv +/** Trusted DeepSeek Harness variables for one bash execution. */ type DshEnvironment = Readonly> ``` @@ -21,6 +23,12 @@ type DshEnvironment = Readonly> The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from. ```ts type-equiv +/** + * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and + * filled by {@link BashExecutor.resolve} from the implementation's config. + * This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a + * fully-resolved {@link BashExecSpec}. + */ interface BashExecRequest { command: string /** Working directory override (default: implementation-configured). */ @@ -59,24 +67,17 @@ interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** - * Explicit per-call sandbox-policy input, overriding the executor's - * configured default mode for THIS call. Never a silent default: a - * consumer sets it only from an explicit policy source — an - * `'allowed-once'` grant a human just issued through `ctx.approval` (the - * escalation flow in the sandbox RFC § Escalation, which outranks), or the - * session's standing override folded from its own `bash/sandbox-mode` - * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session - * choice). A sandboxing executor confines THIS call under the given mode; - * a non-sandboxing executor carries the field and confines nothing (the - * tool layer stamps neither escalation nor overrides without a sandboxing - * executor — see {@link BashExecutor.sandboxMode}). - */ + /** Explicit per-call sandbox mode override. */ sandboxMode?: SandboxMode | undefined } ``` ```ts type-equiv +/** + * A resolved execution spec. {@link BashExecutor.resolve} fills and caps the + * required fields; {@link BashExecutor.start} ignores `timeoutMs` because + * background processes have no executor timeout. + */ interface BashExecSpec { command: string workdir: string @@ -88,31 +89,18 @@ interface BashExecSpec { stdoutMaxBytes: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined - /** - * Bytes to write to the command's stdin (then close it), carried through - * verbatim from {@link BashExecRequest.stdin}. It has no config default, so - * a missing value means "no stdin" and remains an ordinary optional. - */ + /** Bytes to write to stdin before closing it; absent means no stdin. */ stdin?: string | undefined /** - * Extra environment entries, carried through verbatim from - * {@link BashExecRequest.env} and merged by the implementation AFTER its - * credential scrub (an explicit entry wins even when its name matches the - * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no - * config default, absent means "no extra env". + * Ordinary environment entries carried through from + * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * OPTIONAL on the spec for the same reason as `stdin`: absent means no + * ordinary extra environment. */ env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** - * The sandbox mode this call executes under, required-but-nullable so every - * resolved spec states its policy. A sandboxing executor's `resolve()` stamps - * the effective mode (the request's explicit override, else its configured - * default) so `run()`/`start()` read the spec, never the config; - * a non-sandboxing executor carries the request value through verbatim and - * ignores it (`undefined` under such an executor means what its README says: - * unconfined execution). - */ + /** Resolved sandbox mode; ignored by executors that do not confine. */ sandboxMode: SandboxMode | undefined } ``` @@ -126,24 +114,31 @@ interface BashExecSpec { The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. ```ts type-equiv +/** The outcome of one completed (or killed) foreground run. */ interface BashRunResult { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - /** True when the executor's own timeout killed the command. */ + /** + * True when the executor's own timeout was the FIRST cause to cut the command + * short. Mutually exclusive with {@link aborted}: one fused deadline drives + * both the timeout and the caller's cancellation, so a timeout and an abort + * racing before process close report the single first-abort cause, not both + * (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)). + */ timedOut: boolean - /** True when the caller's AbortSignal killed the command. */ + /** + * True when the caller's `AbortSignal` was the FIRST cause to kill the command + * (and it was not the executor's own timeout). Mutually exclusive with + * {@link timedOut} — see there for the first-cause classification. + */ aborted: boolean /** The effective timeout applied to this run (after defaulting/capping). */ timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput - /** - * Sandbox facts, present iff a sandboxing executor ran the command — an - * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See - * {@link BashSandboxInfo} for the `denied` classification semantics. - */ + /** Sandbox execution facts, absent for an unsandboxed executor. */ sandbox?: BashSandboxInfo } ``` @@ -151,6 +146,7 @@ interface BashRunResult { Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: ```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ interface CollectedOutput { /** Collected text — the TAIL of the stream when truncated. */ text: string @@ -168,36 +164,19 @@ A sandbox-consuming executor exposes its configured fallback through `BashExecut A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. ```ts type-equiv +/** + * Sandbox facts for one run, present iff a sandboxing executor handled it. + * Facts are reported independently of process exit status so callers can + * distinguish command failures from policy denials and runner failures. + */ interface BashSandboxInfo { /** The mode the command actually ran under. */ mode: SandboxMode - /** - * True when the executor classifies this run's failure as the sandbox - * denying a file operation. The classification is CONSERVATIVE (a failed - * exit whose stderr carries a filesystem-permission signature) and reads - * the COLLECTED stderr — the bounded in-memory tail per - * {@link CollectedOutput} semantics, so a signature that survives only in a - * spill file is missed toward `denied: false`. A plain command failure - * keeps `denied: false` even under a sandboxed mode. - */ + /** Whether the sandbox denied a file operation. */ denied: boolean - /** - * How completely the runner enforced `mode`'s file effects — see - * {@link SandboxEnforcement}. Absent exactly when `mode` is - * `danger-full-access`: nothing is confined, so there is no enforcement to - * report. - */ + /** How completely the selected runner enforced the requested mode. */ enforcement?: SandboxEnforcement - /** - * True when the executor classifies this failure as the SANDBOX RUNNER - * itself failing (missing binary, refused profile, fail-closed refusal - * before exec) — the command NEVER RAN; this is a sandbox failure, not a - * task failure, and it outranks `denied` (a runner's own error text can - * contain denial words). Only ever stamped on settled BACKGROUND tasks: a - * foreground run surfaces the same condition as the thrown - * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error - * channel; a settled task's facts are its only channel). - */ + /** Whether the sandbox runner failed before the command could run. */ runnerFailed?: boolean } ``` @@ -209,6 +188,11 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o `start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves. ```ts type-equiv +/** + * A background process handle returned by {@link BashExecutor.start}. It is the + * only access path; buffered output remains readable after exit. Executor + * disposal kills running processes and awaits {@link done}. + */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ status: BashProcessStatus @@ -237,6 +221,7 @@ interface BashProcess { `readOutput()` returns the incremental delta and spill recovery facts: ```ts type-equiv +/** One incremental {@link BashProcess.readOutput} read. */ interface BashProcessRead { /** Output produced since the previous read (stderr in a marked section). */ delta: string diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 9237a3cce9..cea69180af 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -9,6 +9,12 @@ Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code- A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`: ```ts type-equiv +/** + * One run: the program source plus everything the runtime acts on. Per the + * explicit-over-implicit convention, defaulting (time budgets, output caps) + * is the implementation's validated config — a request carries no optional + * tuning knobs for a hidden `??` to fill in. + */ interface CodeRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It @@ -31,6 +37,11 @@ interface CodeRunRequest { The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): ```ts type-equiv +/** + * The outcome of one run. An error is a FIELD on a resolved result, never a + * rejection of `run()` — reporting a failed program is the caller's job, not + * an exception path. + */ interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to @@ -51,6 +62,13 @@ interface CodeRunResult { Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): ```ts type-equiv +/** + * A named group of {@link CodeBindingFunction}s the runtime exposes to the + * program as one global object (e.g. `tools`). Function names are arbitrary + * strings — a runtime must treat names like `__proto__` or `constructor` as + * ordinary own properties (null-prototype construction), never as prototype + * collisions. + */ interface CodeBindingNamespace { /** The global identifier the program sees (must be a valid JS identifier). */ global: string @@ -60,6 +78,14 @@ interface CodeBindingNamespace { ``` ```ts type-equiv +/** + * One host-side function exposed to the program as an async callable. The + * runtime bridges calls to it (possibly across a serialization boundary), so + * `args` and the resolution value MUST be structured-cloneable; a runtime + * rejects a non-cloneable value with a descriptive error rather than + * corrupting the run. A rejection of this function surfaces inside the + * program as a rejection of the corresponding call. + */ type CodeBindingFunction = (args: unknown) => Promise ``` @@ -70,6 +96,16 @@ Logs are plain strings in emission order. The runtime captures the program's con Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: ```ts type-equiv +/** + * Why a run failed. The kinds are orthogonal outcomes reported independently + * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * abort is not a timeout, and a substrate death is neither. + * + * - `'exception'` — the program threw or failed to parse/transform. + * - `'timeout'` — an implementation-owned budget expired; the message says which. + * - `'abort'` — {@link CodeRunRequest.signal} fired. + * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + */ interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 45a0ac310e..f6cdfd5b8b 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -20,9 +20,10 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the seqs of the three appended `compact/*` events, the summary blocks, and the shadowed range/seqs plus the estimated token count. +What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. ```ts type-equiv +/** Result of a successful compaction operation. */ interface CompactionResult { /** The seq of the appended `compact/start` event. */ startSeq: number @@ -50,8 +51,15 @@ interface CompactionResult { ## The service -`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. The seam owns no pricing API: [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. +Automatic callers state why policy is running; implementations may treat confirmed overflow more aggressively than ordinary pressure. -Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. +```ts type-equiv +/** Why automatic policy is asking a backend to consider compaction. */ +type CompactionTrigger = 'pressure' | 'context-overflow' +``` + +`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration. + +Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c1ecdcc6a5..1d45f3814a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -36,9 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. - -FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. +> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services use the generated [service catalog](../cordis-catalog/services.md). ## The `…Map → derived-union` pattern @@ -76,17 +74,18 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ## Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package. Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) ```ts type-equiv +/** A string carrying a compile-time-only brand `B`. */ type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs are `CallId`, `SessionId`, and `AgentId`. Capability packages brand their own ids too, such as `TaskId` in [tasks.md](tasks.md). +The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Capability packages brand their own ids too, such as `TaskId` in [tasks.md](tasks.md). ## Content blocks and messages @@ -95,6 +94,10 @@ A conversation is `Message`s; a message is an array of typed **content blocks**. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock @@ -108,6 +111,7 @@ The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBl A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata: ```ts type-equiv +/** Provider ownership and adapter-private replay data for an assistant message. */ interface AssistantProvenance { /** Provider route that produced the message. */ provider: string @@ -123,6 +127,10 @@ interface AssistantProvenance { ``` ```ts type-equiv +/** + * A single message in a conversation history. Loop-derived assistant messages + * always carry provenance; callers may omit it on hand-built foreign history. + */ interface Message { role: 'system' | 'user' | 'assistant' content: ContentBlock[] @@ -134,6 +142,10 @@ interface Message { Where a message came from is itself a merge-extensible sum type: ```ts type-equiv +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } @@ -155,6 +167,7 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. ```ts type-equiv +/** Display metadata for one registered provider route. */ interface LlmProviderInfo { /** Provider route key used by {@link GenerateOptions.provider}. */ id: string @@ -164,6 +177,7 @@ interface LlmProviderInfo { ``` ```ts type-equiv +/** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { /** Provider route that owns this model entry. */ provider: string @@ -177,6 +191,7 @@ interface LlmModelInfo { ``` ```ts type-equiv +/** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string @@ -212,6 +227,10 @@ interface GenerateOptions { Why a model response stopped is a merge-extensible reason: ```ts type-equiv +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } @@ -226,6 +245,13 @@ interface FinishReasonMap { `GenerateOptions.tools` carries `ToolSchema` — the JSON-schema description of a tool, as sent to the model. It is declared in dsh-llm (not dsh-tools) precisely because it is part of the request the loop assembles every step: ```ts type-equiv +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ interface ToolSchema { name: string description: string @@ -247,6 +273,11 @@ On the wire, a loop-built request reads in this order: the `system` slot (the re FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. ```ts type-equiv +/** + * Provider + model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ interface LlmCallConfig { provider: string model: string @@ -263,6 +294,19 @@ A `Session` is an **append-only log** of typed `SessionEvent`s — the single so Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent = { [K in SessionEventType]: { type: K @@ -275,7 +319,9 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -288,42 +334,36 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ## The agent handle -`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. +`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) `InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata: ```ts type-equiv +/** Options specific to durable synthetic context injection. */ interface InjectOptions extends SendOptions { + /** Keep the canonical context tag, or send caller-owned framing verbatim. */ envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } ``` ```ts type-equiv +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent): - * registrations through it — tools, prompt sections/variables, listeners, - * restrictions — are visible to this agent only and unwind when it is - * disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent. - */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. + * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -335,84 +375,36 @@ interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as synthetic context - * rather than a user prompt. The default uses the canonical context tag; - * `options.envelope: 'raw'` preserves caller-owned framing. Does not run the - * model. - * - * In an open turn, inject appends at the current log position except while - * the current tool-call batch executes: accepted context waits FIFO until the - * batch settles, then appends after every recorded result and before turn - * close even when execution is interrupted. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * (inject is synchronous): a failing flush is reported via `agent/error` - * (step `0`) and the logger, never thrown into the caller. - * - * Live-adapter review has validated the canonical tagged-envelope rendering - * against current DeepSeek behavior; provider-specific mismatches belong in - * that adapter, not in the canonical session vocabulary. + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before turn + * close even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: InjectOptions): void /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. + * Clear queued and steering work, including work waiting to start, and abort + * the active step. The supplied reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. - */ + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `SessionId` is branded. `AgentOptions` is merge-extensible and currently includes `provider?` and `model?`; dispatch requires both after `agent/request`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. +## Initiating Agent + +The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns its lifetime and boundary rules. + ## Interception decisions Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its optional `envelope` selects the canonical context tag or caller-owned raw framing, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, framing, and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape. @@ -420,10 +412,13 @@ Each `agent/*` interception waterfall returns a small, seam-specific typed union Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv +/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ interface HookContext { content: ContentBlock[] source: MessageSource + /** Keep the canonical context tag, or use caller-owned framing verbatim. */ envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } ``` @@ -431,6 +426,11 @@ interface HookContext { `agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): ```ts type-equiv +/** + * Prompt interception result. `allow.content` replaces the prompt and each + * `additionalContexts` entry becomes a separate context message. `block` records a + * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. + */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } @@ -439,20 +439,43 @@ type PromptDecision = `agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): ```ts type-equiv +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } ``` +`agent/request-error` receives the original `RequestError`, whose optional provider-neutral `code` supports stable routing without message parsing: + +```ts type-equiv +/** Model-request failure with an optional machine-routable provider code. */ +type RequestError = Error & { code?: string } +``` + +It returns a `RequestErrorDecision`; `retry` opens a new numbered step after the recovery listener's durable mutation, while `fail` preserves that error: + +```ts type-equiv +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } +``` + +`agent/post-step` is awaited after assistant output, real or synthetic tool results, buffered context, and steering are durable but before `step/end`. A cancelled tool batch reaches it with an aborted signal after draining; its signature is `(agent, turn, step, signal)`, and replayable facts remain in the session log rather than a transient payload. + `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. ```ts type-equiv +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ type ContinuationStop = Extract ``` `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): ```ts type-equiv +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 6438b1ba03..f29258857e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -11,8 +11,17 @@ Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types. Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. ```ts type-equiv +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ interface FsTarget { + /** Opaque key for stale guards and target lookup. */ targetKey: FsTargetKey + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ displayPath: string } ``` @@ -20,19 +29,40 @@ interface FsTarget { The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. ```ts type-equiv +/** + * Opaque key for stale guards and target lookup. The local backend uses a + * realpath-like string; a remote backend might use a workspace URI or file id. + * Consumers MUST NOT parse it or assume it is a local absolute path. + */ type FsTargetKey = Branded<'FsTargetKey'> ``` ```ts type-equiv +/** + * Opaque file-version token — the freshness token a write/edit guards against. + * The local backend derives it from high-resolution stat identity and freshness + * fields; a remote backend might use a revision id. The policy layer records it + * for stale checks; consumers may display related metadata but MUST NOT + * interpret this token. + */ type FsVersion = Branded<'FsVersion'> ``` `stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. ```ts type-equiv +/** + * Metadata about a target — what {@link FileSystem.stat} returns. Lets the + * policy layer reject directories/special files before reading and choose + * `readText` vs `streamText` from `size` without probing by failure. `version` + * is the freshness token. `undefined` from `stat` means the target is absent. + */ interface FsInfo { + /** Opaque freshness token of the target right now. */ version: FsVersion + /** Whether the target is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Byte size of a regular file, when the backend can report it. */ size?: number } ``` @@ -40,9 +70,18 @@ interface FsInfo { `lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving. ```ts type-equiv +/** + * Metadata about a path without following the final path component when it is a + * symbolic link. Unlike {@link FsInfo}, this path-level probe can report + * `symlink` so consumers with trust-boundary rules can reject repository-owned + * links before resolving a target. + */ interface FsPathInfo { + /** Opaque freshness token of the path entry right now. */ version: FsVersion + /** Whether the path entry is a regular file, directory, symlink, or other. */ type: 'file' | 'directory' | 'symlink' | 'other' + /** Byte size of the path entry, when the backend can report it. */ size?: number } ``` @@ -50,11 +89,20 @@ interface FsPathInfo { `listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. ```ts type-equiv +/** + * One direct child returned by {@link FileSystem.listDir}. Listing returns + * metadata and resolved targets only; it must not read file contents. + */ interface FsDirEntry { + /** Basename of the child inside the listed directory. */ name: string + /** Whether the child is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Resolved child target for follow-up operations. */ target: FsTarget + /** Opaque freshness token when the backend can report metadata cheaply. */ version?: FsVersion + /** Byte size of a regular file, when the backend can report it. */ size?: number } ``` @@ -64,16 +112,33 @@ interface FsDirEntry { Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv +/** + * Guarded write intent. `createIfAbsent` rejects an existing target with + * `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with + * `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional + * create-or-overwrite, not a third union arm. + */ type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` ```ts type-equiv +/** Outcome of a full-file write. */ interface FsWriteOutcome { + /** Whether the write created a new file or replaced an existing one. */ operation: 'create' | 'update' + /** Opaque version of the file after the write. */ version: FsVersion + /** + * The file's content BEFORE the write, or `null` when the file did not exist + * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text + * (the diff basis), never a diff — a consumer computes the result-time + * contextual diff from `before`/`after` when `before` is present, else falls + * back to a whole-file diff. + */ before: string | null + /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ after: string } ``` @@ -81,17 +146,29 @@ interface FsWriteOutcome { `editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths. ```ts type-equiv +/** A literal-replacement edit request. */ interface FsEditRequest { + /** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */ oldString: string + /** Literal replacement text. An empty string deletes the matched text. */ newString: string + /** Replace every match instead of requiring exactly one. */ replaceAll: boolean } ``` ```ts type-equiv +/** Outcome of a literal edit. */ interface FsEditOutcome { + /** Opaque version of the file after the edit. */ version: FsVersion + /** + * The file's content BEFORE the edit. Raw storage text (LF-normalized by the + * backend), never a diff — a consumer computes the result-time contextual diff + * (the applied hunk with context) from `before`/`after`. + */ before: string + /** The file's content AFTER the edit. */ after: string } ``` @@ -107,8 +184,20 @@ interface FsEditOutcome { The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv +/** + * Minimal structural view of a tool execution the policy plugin needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to this + * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * + * The owner is `agent.session` when present. It is treated as an opaque object + * identity (a `WeakMap` key); this package never reads any of its fields. + */ interface FsPolicyExec { + /** The agent on whose behalf the call runs, when there is one. */ agent?: { + /** The session that owns observed-file state, used as an opaque key. */ session?: object } } @@ -119,10 +208,15 @@ interface FsPolicyExec { A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv +/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ interface FileReadOutcome { + /** 1-based first line requested. */ offset: number + /** Returned lines, already numbered. */ lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ truncatedByBytes?: true } ``` @@ -136,6 +230,11 @@ Observed state is a `WeakMap>` held inside th Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. ```ts type-equiv +/** + * Stable, machine-routable codes for filesystem failures. Carried on + * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` + * results so retry/permission/UI layers can branch without parsing messages. + */ type FsErrorCode = | 'FS_NOT_FOUND' | 'FS_NOT_DIRECTORY' diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index f9489a6d7f..3526687f3e 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -9,6 +9,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it. ```ts type-equiv +/** + * Raw streaming protocol emitted by adapters. + * Block indexes correlate interleaved deltas, and `block-end` carries the + * assembled block. Adapters emit usage before the terminal finish and nothing + * afterward; tool arguments remain raw JSON strings. Failures either throw or + * end with `error`/`aborted`, and consumers must handle both paths. + */ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } @@ -30,7 +37,8 @@ Every adapter MUST obey these, and every consumer may rely on them: - **`usage` before `finish`, nothing after `finish`.** Defer both to the provider's end-of-stream marker so a trailing usage-only chunk can't violate the ordering. - **Tool-call `arguments` stay raw JSON strings end-to-end.** Partial fragments stream via `argumentsDelta`; a provider that hands back parsed objects re-stringifies at `block-end`. -- **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop translates a finish-error/aborted into a turn error — it never logs a normal completed assistant message for a failed step. +- **Two sanctioned error paths.** A failure may either THROW from `stream()` (transport/protocol errors) **or** end the stream with `finish {kind:'error'|'aborted'}` (provider in-band errors, for adapters that can't throw mid-stream). Consumers must handle *both*. The agent loop closes the failed step and offers either form to `agent/request-error`; absent recovery it becomes a turn error, and no normal completed assistant message is logged for that request. +- **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test (mock server asserting the received header, or the library's header hook for a library-backed adapter). - **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message unless an `agent/step-result` listener rewrote the content. On a later request, `LlmService` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content and provenance without the private state. @@ -41,18 +49,36 @@ This contract was pinned down by two deliberately independent implementations: ` The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). ```ts type-equiv +/** + * Static public application identity sent to LLM providers. + * + * Every field is a public product fact, safe on every request: no secrets, + * local paths, session ids, prompt text, or per-user identifiers belong here, + * and nothing per-request may influence the values. + */ interface AppIdentity { + /** `User-Agent` product token (lowercase, hyphenated). */ product: string + /** Product version; sourced from package metadata, never hand-copied. */ version: string + /** Public home URL of the app, used as the `User-Agent` comment. */ url: string } ``` ## `TokenUsage` -Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. +Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. `reasoningTokens`, when present, is informational detail already included in `outputTokens`; totals must not add it again. ```ts type-equiv +/** + * Token accounting for one model call (cache fields are optional). + * + * Counts are DISJOINT: `inputTokens` is uncached input only; cached input is + * reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input = + * sum of the three). Adapters whose providers fold cache hits into a total + * prompt count (DeepSeek's `prompt_tokens`) subtract them out. + */ interface TokenUsage { inputTokens: number outputTokens: number @@ -66,13 +92,86 @@ interface TokenUsage { `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with its provider/model provenance. A consumer that needs the assembled result without re-implementing the fold uses this. +```ts public-api +/** + * Incrementally assembles raw {@link StreamChunk}s into complete + * {@link ContentBlock}s and a final assistant {@link Message}. + * + * The agent loop feeds it while logging raw chunks for replay fidelity, then + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * + * Tolerant of delta-only protocols (no block-start/end); deltas arriving for + * an index already closed by `block-end` are ignored (malformed stream) so a + * misbehaving adapter cannot grow memory or corrupt a completed block. + */ +declare class BlockAssembler { + /** + * Feed one chunk into the assembly state. + * @param chunk - the next raw chunk, in stream order. + */ + push(chunk: StreamChunk): void; + /** + * Assemble all blocks seen so far, in stream order. + * @returns one block per seen index; an open block assembles from its + * accumulated deltas (an unknown block type never closed by `block-end` throws). + */ + blocks(): ContentBlock[]; + /** Usage from the `usage` chunk; undefined until one arrives. */ + get usage(): TokenUsage | undefined; + /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ + get finish(): FinishReason; + /** Adapter-private replay state from the terminal finish chunk, if any. */ + get replayState(): unknown; + /** + * The assembled assistant message. + * @returns an assistant-role message over `blocks()` (same open-block assembly rules). + */ + message(): Message; +} +``` + ## The seam `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +```ts public-api +/** + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + */ +declare abstract class LlmAdapter { + /** + * Describe one provider route owned by this adapter. + * @param provider - a route passed to `registerAdapter()` for this instance. + * @returns detached display metadata whose id must equal `provider`. + */ + providerInfo(provider: string): LlmProviderInfo; + /** + * List models this adapter can currently advertise for one owned provider. + * The result is advisory: an adapter may accept unlisted model ids, and + * consumers must not turn absence into request rejection. + * @param _provider - one provider route owned by this adapter. + * @returns discoverable models in adapter-preferred order. + */ + listModels(_provider: string): Promise; + /** + * Stream one model call as raw chunks. The only required method. + * @param options - the fully-assembled request; implementations must honor `options.signal`. + * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`. + */ + abstract stream(options: GenerateOptions): AsyncIterable; +} +``` + `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index c1e02ae743..72ebcc5852 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -17,6 +17,11 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv +/** + * A backend-resolved, per-session local artifact location. The path is an + * absolute target path and can name an artifact that has not materialized yet. + * Consumers must treat it as a location hint, never as an authorization token. + */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ readonly kind: string @@ -32,6 +37,9 @@ Per-session metadata travels **separately** from the event log: format version, Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * Immutable validated storage metadata, kept outside the conversation event log. + */ interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the @@ -48,13 +56,8 @@ interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by - * this session — the seed boundary. Set when a fork seeds a child with a - * prefix of the parent's log (= the seeded prefix length); absent/0 means the - * session produced all its own events. Persisted so a reload reconstructs the - * boundary instead of re-deriving it from the full stored log, and so a replay - * harness can skip the inherited prefix when deriving the child's OWN script - * (the seeded events are the parent's, not this child's model calls). + * How many leading events were inherited through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number } @@ -65,20 +68,17 @@ interface SessionHeader { Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv +/** + * Options for creating a {@link Session} via the store. `seed` replays/forks + * an existing event log; `meta` carries the caller-supplied storage fields the + * store folds into a {@link SessionHeader}. + */ interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 6b7b212373..5b20febc3e 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -9,18 +9,30 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox `SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv +/** + * File-effect policy for confined processes. `read-only` permits only required + * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a + * backend-defined temp area; `danger-full-access` bypasses confinement. Network + * and process visibility are outside this vocabulary. + */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' ``` Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`. ```ts type-equiv +/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */ type ConfinedSandboxMode = Exclude ``` Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. ```ts type-equiv +/** + * Enforcement completeness for this host. `partial` means an active backend or + * older kernel ABI cannot govern every promised file effect; callers requiring + * an absolute boundary must not treat it as `full`. + */ type SandboxEnforcement = 'full' | 'partial' ``` @@ -29,6 +41,15 @@ type SandboxEnforcement = 'full' | 'partial' The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. ```ts type-equiv +/** + * What one confined execution is allowed to touch — carried PER CALL, not + * fixed on the provider: two consumers may confine under different policies + * at the same instant (bash under `read-only` while a confined child agent + * needs its state directory writable), and an approved escalated retry is a + * new call with a wider policy. Defaulting/resolution is the consumer's + * explicit step (its config owns the fallback chain); the provider treats + * the policy as fully specified. + */ interface SandboxPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode @@ -42,6 +63,11 @@ interface SandboxPolicy { `ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. ```ts type-equiv +/** + * A {@link SandboxProvider.confine} result: the argv to spawn in place of + * the caller's own, plus the enforcement completeness the selected backend + * achieves for it. + */ interface ConfinedArgv { /** The wrapped argv (runner, profile, separator, then the caller's argv). */ argv: string[] @@ -57,17 +83,9 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr - * substrings produced when the sandbox binary is missing, refuses its - * profile, or fails closed before exec'ing the command (`bwrap: `, - * `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own - * error prefix and the shell's runner-not-found message). ORTHOGONAL to - * {@link denialSignatures}: a denial is the confined COMMAND being blocked - * (the sandbox working as designed); a runner failure means the command - * NEVER RAN and must surface as a sandbox failure, not a task failure — - * consumers check these signatures FIRST (a runner's own error text may - * contain denial words, e.g. an unopenable grant root reporting - * `Permission denied`). + * Case-insensitive signatures for runner failure before command execution. + * Consumers check these before denial signatures: runner failure means the + * command never ran, while denial means confinement worked and blocked it. */ runnerFailureSignatures: readonly string[] } diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index 66d5f40de9..d79f464b3e 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -9,12 +9,18 @@ Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index `ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object. ```ts type-equiv +/** An opaque, identity-compared scope key. */ type ScopeKey = object ``` `Scoped` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument. ```ts type-equiv +/** + * A routing-only event receiver built by {@link scopeTarget}. The type + * parameter records the subject type for dispatch checking; the carrier does + * not expose the subject's properties. Event payloads carry the real subject. + */ type Scoped = object & { readonly [ScopedBrand]: T } ``` @@ -23,9 +29,13 @@ type Scoped = object & { readonly [ScopedBrand]: T } `Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers. ```ts type-equiv +/** A minted registration scope and its quiescent disposal boundaries. */ interface Scope { + /** Context through which scope-owned registrations are made. */ ctx: Context + /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */ rawDispose: () => Promise | void + /** Dispose every scope-owned registration; racing calls await the same completion. */ dispose(): Promise } ``` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 86d2259f7f..4652358162 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -9,23 +9,34 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses `SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. ```ts type-equiv -export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +/** Whether an event is current model context, replaced context, or raw-log-only. */ +type SessionEventSurface = 'current' | 'shadowed' | 'log-only' ``` ```ts type-equiv -export interface SessionRecord { +/** Lightweight identity and source availability for one logical session. */ +interface SessionRecord { + /** Cloned session header selected from the live-preferred corpus. */ header: SessionHeader + /** Whether the id currently exists in `ctx.sessions`. */ live: boolean + /** Whether the active persistence backend currently materializes the id. */ persisted: boolean } ``` ```ts type-equiv -export interface SessionEventRecord { +/** Lightweight metadata for one event within a logical session. */ +interface SessionEventRecord { + /** Session that owns the event. */ sessionId: SessionId + /** Monotonic event seq within the session. */ seq: number + /** Discriminant of the session event. */ type: SessionEventType + /** Event timestamp in Unix epoch milliseconds. */ time: number + /** Event placement in the folded session surface. */ surface: SessionEventSurface } ``` @@ -35,24 +46,35 @@ export interface SessionEventRecord { `SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive. ```ts type-equiv -export interface SessionLineageNode { +/** Recursive descendant node in a session-lineage trace. */ +interface SessionLineageNode { + /** Detached logical-corpus record for this descendant. */ session: SessionRecord + /** Direct children, each carrying its own recursive descendants. */ descendants: SessionLineageNode[] } ``` ```ts type-equiv -export type SessionLineageTrace = { +/** Known ancestry and descendants for one logical session. */ +type SessionLineageTrace = { + /** Detached record for the session that was traced. */ target: SessionRecord + /** Known parents from the immediate parent outward. */ ancestors: SessionRecord[] + /** Complete known descendant trees rooted at the target's direct children. */ descendants: SessionLineageNode[] } & ( | { + /** The complete parent chain is present in the logical corpus. */ complete: true + /** Detached record at the top of the complete lineage. */ root: SessionRecord } | { + /** The parent chain leaves the visible logical corpus. */ complete: false + /** First parent id that is not present in the logical corpus. */ unresolvedParentId: SessionId } ) @@ -63,20 +85,31 @@ export type SessionLineageTrace = { The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. ```ts type-equiv -export interface SessionEventReadRequest { +/** Request for one event plus raw neighboring log context. */ +interface SessionEventReadRequest { + /** Session that owns the target event. */ sessionId: SessionId + /** Target event seq. */ seq: number + /** Number of preceding raw events to include. */ before?: number + /** Number of following raw events to include. */ after?: number } ``` ```ts type-equiv -export interface SessionEventWindow { +/** Full target event and a bounded raw-log window. */ +interface SessionEventWindow { + /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Full cloned target event. */ target: SessionEvent + /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] + /** First seq included in `events`. */ startSeq: number + /** Last seq included in `events`. */ endSeq: number } ``` @@ -86,19 +119,29 @@ export interface SessionEventWindow { Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement. ```ts type-equiv -export interface SessionEventTraceRequest { +/** Request for direct surface and provenance relationships around one event. */ +interface SessionEventTraceRequest { + /** Session that owns the target event. */ sessionId: SessionId + /** Target event seq. */ seq: number } ``` ```ts type-equiv -export interface SessionEventTrace { +/** Direct surface and provenance relationships for one event. */ +interface SessionEventTrace { + /** Lightweight target record. */ target: SessionEventRecord + /** Immediate positional replacement event, when the target was shadowed. */ replacedBy?: number + /** Positional replacers from the immediate replacement to the final replacement. */ replacementChain: number[] + /** Surface nodes directly removed when the target itself performed a replacement. */ replacedEventSeqs: number[] + /** Direct logged provenance sources in their recorded order. */ sourceEventSeqs: number[] + /** Later events that directly name the target as a provenance source, in log order. */ derivedEventSeqs: number[] } ``` @@ -108,7 +151,8 @@ export interface SessionEventTrace { The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. ```ts type-equiv -export type SessionQueryErrorCode = +/** Stable machine-routable failure taxonomy for exact session reads and traces. */ +type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' | 'SESSION_QUERY_INVALID_LINEAGE' diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 0e882bae33..3668191fb2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -9,6 +9,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t `ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history. ```ts type-equiv +/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ type ContextEnvelope = 'context' | 'raw' ``` @@ -17,30 +18,43 @@ type ContextEnvelope = 'context' | 'raw' The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv +/** + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. + */ interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — a drained message + * batch or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * fires the awaited `session/flush` checkpoint at every turn end, so the turn + * boundary is also the durable-commit boundary. + */ 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * A queued prompt an `agent/prompt-submit` listener VETOED — the durable - * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` - * is the original prompt the listener rejected; `reason` is the veto text - * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a - * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller - * supply its own complete framing; `meta` is persisted JSON hidden from the - * model. + * own the complete model-facing frame; `meta` is durable JSON state omitted + * from the model projection. */ 'context/message': { content: ContentBlock[] @@ -57,34 +71,29 @@ interface SessionEventMap { * usage record). `usage` is absent when the adapter reported none. */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + /** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - /** - * The agent's whole todo list, carried as a full snapshot and replaced - * wholesale on each write — the current list is the most recent `todo/write` - * (last-write-wins on replay, no fold). Appended by an owning agent via - * `session.append('todo/write', { todos })`. - * - * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches - * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — - * it is durable, replayable UI state, distinct from the conversation history. - * It is a `SessionEventMap` member riding the existing `session/event` emit, - * not a first-class Cordis `interface Events` notification, so it has no - * cordis-catalog row. - */ + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, - * with the {@link RequestHeaderReason} it was recorded whole. Appended by - * the loop inside the step, before dispatch, on a loop instance's first - * request-building step (`'initial'`/`'resume'`) or when a later request's - * header changes (`'change'`); always records what the request actually - * used, post-`agent/request`. Reconstruction reads the latest snapshot. NOT a - * {@link SurfaceEventType}: it produces no LLM message — it is the request - * envelope, logged so every request is a pure function of the session log - * (the reconstructability RFC). + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } } @@ -95,8 +104,21 @@ interface SessionEventMap { The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires). See the [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md). ```ts type-equiv -export interface TodoItem { +/** + * One entry in an agent's todo list — the unit of the `todo/write` + * {@link SessionEventMap} event's whole-list snapshot. + * + * Deliberately minimal: a human-readable `content` line and a three-state + * `status`. No id, priority, or `activeForm` — the list is replaced wholesale + * on every write (last-write-wins), so entries need no stable identity, and the + * status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a + * todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally + * requires). + */ +interface TodoItem { + /** What this task is — a short imperative line shown in the UI. */ content: string + /** Lifecycle state. `in_progress` marks the single task being worked now. */ status: 'pending' | 'in_progress' | 'completed' } ``` @@ -106,8 +128,13 @@ export interface TodoItem { The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv -export interface EpochHeader { - /** The conversation's call configuration (provider + model + sampling scalars). */ +/** + * Logged request state outside derived history: call config, system prompt, + * tools, and prefix. The latest full `request/header` snapshot reconstructs it; + * canonical empty optional fields are absent. + */ +interface EpochHeader { + /** The conversation's call configuration (provider, model, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string @@ -131,6 +158,19 @@ Canonical form: an empty system prompt, an empty tool list, and an empty session A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent = { [K in SessionEventType]: { type: K @@ -143,7 +183,9 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -163,7 +205,12 @@ The five message-producing types (`SurfaceEventType` — `user/message`, `assist ### `SurfaceEventType` — the message-producing subset of event types ```ts type-equiv -export type SurfaceEventType = +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' @@ -174,7 +221,19 @@ export type SurfaceEventType = ### `SurfaceOp` — how an event entered the surface ```ts type-equiv -export type SurfaceOp = +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +type SurfaceOp = | 'append' | { op: 'replace'; start: number; end: number } ``` @@ -184,8 +243,18 @@ export type SurfaceOp = ### `SurfaceIntent` — the parameter to `session.append()` ```ts type-equiv -export interface SurfaceIntent { +/** + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. + */ +interface SurfaceIntent { surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ sourceEventSeqs?: number[] } ``` @@ -194,26 +263,168 @@ Required for `SurfaceEventType` events — every message-producing event must de The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty. -### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay +### `SessionSurface` — the live readonly surface projection -`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. `SurfaceManager` uses the same transitions for its incremental cache without retaining replacement history. Its `replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite. +`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. ```ts type-equiv -export interface SurfaceFoldReplacement { +/** Readonly live projection of the message-producing session events. */ +interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number +} +``` + +### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay + +`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. + +```ts type-equiv +/** One replacement operation observed while folding a session surface. */ +interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ seq: number + /** Declared inclusive start seq of the replaced surface range. */ start: number + /** Declared inclusive end seq of the replaced surface range. */ end: number + /** Actual surface entries removed by the operation, in surface order. */ shadowedSeqs: number[] } ``` ```ts type-equiv -export interface SurfaceFoldResult { +/** Complete result of replaying the surface operations in a session log. */ +interface SurfaceFoldResult { + /** Current surface event sequences in model-visible order. */ nodes: number[] + /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } ``` +## `Session` public API + +The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). + +```ts public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability RFC). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: @@ -237,6 +448,10 @@ An explicit `boundary` lets callers fork from a previous completed turn even if ## What started a turn: `TurnTriggerMap` ```ts type-equiv +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** @@ -254,6 +469,9 @@ interface TurnTriggerMap { ## Why a turn ended: `TurnEndReasonMap` ```ts type-equiv +/** + * Why a turn ended. Merge-extensible sum type. + */ interface TurnEndReasonMap { completed: { kind: 'completed' } aborted: { kind: 'aborted'; reason?: string } @@ -265,26 +483,16 @@ interface TurnEndReasonMap { */ error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked every prompt before the first step. The zero-step turn still + * records a balanced durable boundary and the veto reason. */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a - * persistence backend later closed the orphaned (open) turn on reload so the - * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no - * loop ever emits this. Its events are real (they were durably appended before - * the crash) and are PRESERVED, not discarded: a single turn can be huge in a - * long-horizon task (many steps, large tool output), so truncating it would - * lose real work. The marker records that the turn was cut short, not that the - * model completed it. See the session-persistence RFC. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 93189ba2cb..daccb38550 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -11,9 +11,25 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. ```ts type-equiv +/** Provider interface for one source of skills, such as local directories or a remote registry. */ interface SkillProvider { + /** Unique provider name in the `ctx.skills` registry. */ readonly name: string + /** + * List available skill candidates for the current lookup context. Provider + * plugins register synchronously during `apply()`; remote initialization, + * authentication, and discovery are awaited inside this method. Implementations + * should settle promptly when `options.signal` aborts. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns provider candidates with precedence ranks and opaque locators. + */ readonly list: (options: SkillLookupOptions) => Promise + /** + * Load a complete skill body for a previously listed candidate. + * @param candidate - the winning candidate originally returned by this provider. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill body, or `undefined` if it is no longer loadable. + */ readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } ``` @@ -37,6 +53,7 @@ The project root is the nearest ancestor containing `.git`; without one, the cur Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`/SKILL.md`) and flat Markdown files (`.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. ```ts type-equiv +/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) ``` @@ -45,13 +62,21 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' `SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name. ```ts type-equiv +/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ interface SkillSummary { + /** Kebab-case identifier used with the `skill` tool. */ readonly name: string + /** Short routing description shown to the model. */ readonly description: string + /** Optional extra routing guidance shown to the model. */ readonly whenToUse?: string + /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ readonly disableModelInvocation?: boolean + /** Discovery source that produced this winning skill. */ readonly source: SkillSource + /** Provider that owns this skill body. */ readonly provider: string + /** Provider-specific base for relative resources. */ readonly resourceBase?: SkillResourceBase } ``` @@ -59,10 +84,15 @@ interface SkillSummary { `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. ```ts type-equiv +/** Provider catalog entry used by the registry to merge and later load skills. */ interface SkillCandidate extends SkillSummary { + /** Lower ranks win duplicate skill names before provider registration order is considered. */ readonly rank: number + /** Opaque provider-owned handle passed back to `provider.get()`. */ readonly locator: unknown + /** Absolute file path when the provider has one. */ readonly path?: string + /** Parsed optional metadata object from provider-specific skill frontmatter. */ readonly metadata?: Readonly> } ``` @@ -70,6 +100,7 @@ interface SkillCandidate extends SkillSummary { `SkillDefinition` is the complete parsed result returned by `ctx.skills.get()` and used by the `skill` tool. `resourceBase` tells the tool how to render relative-resource guidance for local, URL, or provider-managed skills. ```ts type-equiv +/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ type SkillResourceBase = | { readonly kind: 'directory'; readonly path: string } | { readonly kind: 'url'; readonly url: string } @@ -77,9 +108,13 @@ type SkillResourceBase = ``` ```ts type-equiv +/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */ interface SkillDefinition extends SkillSummary { + /** Markdown instruction body after any provider-specific metadata removal. */ readonly content: string + /** Absolute file path when the skill came from disk. */ readonly path?: string + /** Parsed optional metadata object from frontmatter. */ readonly metadata?: Readonly> } ``` @@ -87,9 +122,8 @@ interface SkillDefinition extends SkillSummary { Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches. ```ts type-equiv -type SkillRegistration = Omit & { - readonly provider?: string -} +/** Runtime skill contribution accepted by `ctx.skills.register()`. */ +type SkillRegistration = Omit & { readonly provider?: string } ``` ## Lookup and configuration @@ -97,8 +131,11 @@ type SkillRegistration = Omit & { Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. ```ts type-equiv +/** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { + /** Workspace selector for the current lookup. */ readonly cwd?: string | undefined + /** Abort discovery or loading work for the current caller. */ readonly signal?: AbortSignal | undefined } ``` @@ -106,7 +143,9 @@ interface SkillLookupOptions { The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. ```ts type-equiv +/** Skill registry configuration. */ interface Config { + /** Maximum number of completed cwd/provider catalogs kept in memory. */ readonly collectCacheMaxEntries?: number } ``` diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md index 4e8ced8258..d046874b0e 100644 --- a/docs/core-data-structures/spill.md +++ b/docs/core-data-structures/spill.md @@ -9,15 +9,28 @@ Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/typ `saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path). ```ts type-equiv +/** One request to persist text to a spill artifact. */ interface SaveTextSpill { owner: SpillOwner source: SpillSource + /** + * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes + * it to a single safe path segment before use — it is a hint, never a path. + */ suggestedName: string + /** The full text to persist (UTF-8). */ content: string } ``` ```ts type-equiv +/** + * Save-time storage namespace for a spilled artifact. The session id lets a + * backend group storage under the producing session, but the returned + * {@link SpillLocator} is the model-facing handle. Forked sessions inherit + * locators already present in the seeded log; those artifacts are not copied or + * re-owned, and spills produced after the fork use the child session id. + */ interface SpillOwner { sessionId: SessionId } @@ -26,9 +39,17 @@ interface SpillOwner { `SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. ```ts type-equiv +/** + * Provenance of one spilled artifact — recorded by the backend for a readable + * filename and inspection. Not interpreted for access control; purely + * descriptive. + */ interface SpillSource { + /** The tool whose result was spilled (e.g. `web_fetch`). */ toolName: string + /** The model-issued call id the result belongs to. */ callId: CallId + /** A short human label for the artifact (e.g. `result`). */ label: string } ``` @@ -36,6 +57,7 @@ interface SpillSource { ## The result ```ts type-equiv +/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */ interface SpillRef { locator: SpillLocator bytes: number @@ -46,6 +68,11 @@ interface SpillRef { `SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. ```ts type-equiv +/** + * Opaque model-facing handle for one spilled artifact. A local backend may use a + * filesystem path; a remote or database backend may use a URI or key. Consumers + * render it with {@link SpillRef.retrievalHint}, but do not parse it. + */ type SpillLocator = Branded<'SpillLocator'> ``` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 28093165bd..d279f26a9d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -11,10 +11,22 @@ Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/suba A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. ```ts type-equiv +/** + * Which START-TIME features a provider supports. Checked by the service before delegating to + * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks + * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent + * degradation" rule). These static flags cover features needed before a run exists; runtime + * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence + * is the capability. + */ interface SubagentCapabilities { + /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean + /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean + /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean + /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -24,14 +36,60 @@ interface SubagentCapabilities { The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv +/** + * What a caller asks for when starting a subagent. The tool layer builds this + * from the model's `{ description, prompt }` plus its own config; the service + * validates {@link SubagentCapabilities} against the named provider, then + * passes it to {@link SubagentProvider.start}. + */ interface SubagentStartRequest { + /** The task/prompt for the child agent (a user message in the child session). */ readonly prompt: ContentBlock[] + /** + * The spawning ("parent") agent — the one whose tool call started this + * subagent. REQUIRED: in-process backends read `parent.session.header` for + * the working directory, the `parentSession` lineage to stamp on the child, + * and the parent's delegation depth. Out-of-process backends (ACP) ignore it. + */ readonly parent: Agent + /** + * Cancellation signal from the spawning context (the tool's `exec.signal`). + * This is the canonical cancellation channel both before and after startup: + * a provider rejects `start()` after cleaning partial resources when it + * fires before publication, and cancels a published child when it fires + * afterward. + */ readonly signal: AbortSignal + /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions + /** + * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; + * a successful child returns the matching value as {@link SubagentResult.structured}. + */ readonly outputSchema?: StructuredOutputSchema + /** + * Optional absolute delegation-depth cap for the child being started: its + * computed depth must be less than or equal to this non-negative safe + * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at + * start otherwise. + */ readonly maxDepth?: number + /** + * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; + * rejected at start otherwise. In-process backends apply it as a scoped + * `tools.restrict()` in the child's creation window: the named tools vanish + * from the child's prompt AND refuse to execute (one visibility), with loud + * unknown-name validation. + */ readonly toolFilter?: ToolRestriction + /** + * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; + * rejected at start otherwise. In-process backends register it as a scoped + * `deployment:persona` section on the child, SHADOWING the deployment's + * persona for this child alone — same template semantics as the deployment + * persona (strict `{{…}}` interpolation against the registered variables). + */ readonly persona?: string } ``` @@ -43,9 +101,21 @@ interface SubagentStartRequest { The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv +/** + * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. + */ interface SubagentResult { + /** The child's final assistant output (the last assistant message's content). */ readonly output: ContentBlock[] + /** + * The structured result after a requested `outputSchema` was successfully + * satisfied. Requesting a schema does not guarantee presence: a provider can + * end with `stopReason: 'error'` when the child fails or finishes without a + * valid capture. Shape is validated against the request schema by the + * provider; `unknown` here because the seam is schema-agnostic. + */ readonly structured?: unknown + /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } ``` @@ -53,11 +123,22 @@ interface SubagentResult { `SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: ```ts type-equiv +/** + * Why a subagent run ended. Merge-extensible (a backend may add variants); + * consumers branch on the known cases and fall through `default`. The known + * cases mirror the harness turn-end vocabulary so the tool layer can map a + * non-`completed` result to an `isError` tool result. + */ interface SubagentStopReasonMap { + /** The child finished its turn normally. */ completed: 'completed' + /** The run was cancelled by its request signal or by disposal. */ aborted: 'aborted' + /** The child failed (model error, transport error). */ error: 'error' + /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' + /** The child declined the task. */ refusal: 'refusal' } ``` @@ -67,29 +148,90 @@ interface SubagentStopReasonMap { `SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. ```ts type-equiv +/** + * Child handle returned only after readiness. Consumers await {@link result} and must always + * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime + * capability discovery; narrow their presence before calling. + */ interface SubagentRun { - readonly id: AgentId + /** + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. + */ + readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ + readonly localAgent: Agent | undefined + /** + * Resolves with the child's terminal {@link SubagentResult} when the run + * settles. Does NOT reject on a child-level failure — a model/transport + * failure resolves with `stopReason: 'error'` so the consumer maps it to an + * `isError` tool result. Rejects only on an infrastructure fault the seam + * cannot represent as a stop reason. + */ readonly result: Promise + /** + * Cancel remaining work, reach child quiescence, and release the run's + * resources (in-process: dispose the owned agent and remove its session; + * ACP: kill and reap the subprocess). Idempotent. + */ dispose(): Promise + /** + * OPTIONAL (steering capability): send additional content to the running + * child between steps. Present only on providers that support live steering. + */ sendMessage?(content: ContentBlock[]): void + /** + * OPTIONAL (resume capability): send a follow-up task to a settled child, + * continuing its session, and return a fresh run for the continuation. + */ resume?(content: ContentBlock[]): Promise } ``` +A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. + ## The provider seam: `SubagentProvider` Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv +/** + * A subagent backend: one transport for running a child agent (in-process + * spawn/fork, ACP to another process, …). Implementations register under a + * unique name via {@link SubagentService.registerProvider}; multiple providers + * coexist in one context (unlike the single-implementation bash seam). The + * Providers are trusted same-process implementations; callers treat their + * descriptors and returned values as borrowed immutable data. + */ interface SubagentProvider { + /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ readonly name: string + /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities + /** + * Whether the child sees the parent's completed-turn prefix. This is descriptive, not a + * service-validated start capability: the model-facing tool derives truthful wording from it. + * It says nothing about tool registration, injected services, or authority inheritance. + */ readonly inheritsParentContext: boolean + /** + * Establish a child and return its handle only after publication. The + * service has already validated that every requested start-time capability + * is supported, so an implementation may assume e.g. `request.maxDepth` is + * honorable when present. If setup fails or `request.signal` aborts before + * fulfillment, the provider owns and cleans all partial resources before this + * promise rejects. Ownership transfers to the caller only on fulfillment. + */ start(request: SubagentStartRequest): Promise } ``` -`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 4b6f1e6625..04c86aabe6 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -9,7 +9,12 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- `AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together. ```ts type-equiv +/** Merge-extensible context for one prompt assembly. */ interface AssembleContext { + /** + * Scope whose providers and waterfall listeners participate. When absent, + * only global providers and subject-less listeners participate. + */ scope?: ScopeKey } ``` @@ -19,8 +24,11 @@ interface AssembleContext { `ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. ```ts type-equiv +/** Tool schemas visible in one assembly and their pre-restriction name set. */ interface ToolProviderResult { + /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] + /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ readonly knownNames?: readonly string[] } ``` @@ -30,9 +38,21 @@ interface ToolProviderResult { `PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. ```ts type-equiv +/** One contributed section of the system prompt (registry input). */ interface PromptSection { + /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string + /** + * Sections are concatenated in ascending order. Convention: `-100` is the + * harness identity, `0` the deployment persona, tool guidance uses 100–199; + * other negative orders also render before the persona. + */ readonly order: number + /** + * Static text or a provider evaluated at each assembly with that assembly's + * {@link AssembleContext}. The text may reference `{{variable}}`s — they are + * interpolated later, by {@link renderPrompt}. + */ readonly text: string | ((context: AssembleContext) => string) } ``` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 0482912af7..ebe2062a84 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -7,6 +7,10 @@ Types shared by long-running producers, `ctx.tasks`, and task control surfaces. `TaskId` is a [branded id](core.md#branded-ids) generated as `-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. ```ts type-equiv +/** + * Producer-defined task kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ interface TaskKindMap { bash: 'bash' subagent: 'subagent' @@ -20,6 +24,11 @@ interface TaskKindMap { `TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv +/** + * Producer declaration passed to {@link TaskService.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity and lifecycle state. + */ interface TaskStart { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: TaskKind @@ -44,6 +53,7 @@ interface TaskStart { `TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. ```ts type-equiv +/** Hooks through which the runtime controls and observes producer work. */ interface TaskHooks { /** * Request termination. Must be synchronous, idempotent, and eventually settle @@ -67,6 +77,7 @@ interface TaskHooks { ``` ```ts type-equiv +/** Terminal result supplied by a producer through {@link TaskHooks.done}. */ interface TaskOutcome { /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' @@ -82,6 +93,10 @@ interface TaskOutcome { Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state. ```ts type-equiv +/** + * A read-only projection of one task, safe to hand to listeners and tools — + * a fresh object per call, never live registry state. + */ interface TaskSnapshot { /** The registry-issued id (`-N`). */ id: TaskId @@ -112,6 +127,7 @@ interface TaskSnapshot { ``` ```ts type-equiv +/** Output and post-read state returned by {@link TaskService.read}. */ interface TaskRead { /** * Stream kinds: the consuming delta since the previous read. Final-output diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index e082ea08a9..880ec79d7d 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -7,6 +7,7 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter ## `TokenMeasurement` ```ts type-equiv +/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number @@ -28,6 +29,7 @@ interface TokenMeasurement { ## `TokenSurfaceNode` ```ts type-equiv +/** One token-priced node in the current ordered session surface. */ interface TokenSurfaceNode { /** Durable sequence number of the surface event. */ readonly seq: number diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 3b62d044ef..7cc0910d90 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -6,9 +6,10 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index ## `ToolDefinition` — a registered tool -A `ToolSchema` (the model-facing fields) plus the `execute` function and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`presentCall`/`presentResult` must never leak into a model request. +A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. ```ts type-equiv +/** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { execute(args: unknown, exec: ToolRunContext): Promise /** @@ -19,6 +20,20 @@ interface ToolDefinition extends ToolSchema { * cooperative implementation that can reach quiescence when the signal aborts. */ timeoutMs?: number + /** + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. + * + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the + * [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. + */ + isConcurrencySafe?(args: unknown): boolean /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -49,6 +64,7 @@ Plugin authors write per-property specs with a boolean `required: true`, and a t Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv +/** One schema-spec property entry. */ interface SchemaProp { type: SchemaType /** Per-property required flag (NOT the JSON Schema top-level required array). */ @@ -57,7 +73,10 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** Default value. */ + /** + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. + */ default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec @@ -67,12 +86,29 @@ interface SchemaProp { ``` ```ts type-equiv +/** + * The author-facing parameter schema: a shallow map of property name to + * {@link SchemaProp}. Required-ness is a per-property boolean (`required: + * true`), not a separate array. + */ type SchemaSpec = Record ``` `SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional: ```ts type-equiv +/** + * Infer the TS argument type for a complete {@link SchemaSpec}. + * + * Properties marked `required: true` are required keys; all others are + * genuinely optional keys (`?`), so callers may omit them entirely. + * + * Example: + * ```ts + * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> + * // → { path: string; limit?: number } + * ``` + */ type InferArgs = Simplify< & { [K in RequiredKeys]: InferPropValue } & { [K in Exclude>]?: InferPropValue } @@ -88,8 +124,14 @@ Registration is a trusted same-process contract. The registry borrows the typed `ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. ```ts type-equiv +/** + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. + */ interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -99,14 +141,20 @@ interface ToolRestriction { `ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. ```ts type-equiv +/** Opaque call identity that permits correlation without exposing mutable execution state. */ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ``` ```ts type-equiv +/** + * Caller-supplied description of one tool call. {@link ToolRegistry.execute} + * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * callers do not choose that token. + */ interface ToolExecutionInput { readonly callId: CallId readonly name: string - /** Parsed JSON arguments (unknown — tools validate their own input). */ + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent @@ -123,6 +171,12 @@ interface ToolExecutionInput { A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. ```ts type-equiv +/** + * Runtime context handed to a tool implementation after the registry has + * accepted a {@link ToolExecution}. A composite tool uses + * {@link deferContext} to ferry context produced by nested dispatches back to + * the outer result; the loop appends it only after the outer `tool/result`. + */ interface ToolRunContext extends ToolExecution { /** * Defer one nested-dispatch context until this tool's final result reaches @@ -133,7 +187,26 @@ interface ToolRunContext extends ToolExecution { } ``` +The agent loop asks the registry for each pending call's execution mode and uses it to form exclusive barriers and rolling-pool parallel runs: + ```ts type-equiv +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ +type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } +``` + +```ts type-equiv +/** + * One pending tool call inside the registry pipeline. Parsed arguments cross + * one lossless-JSON materialization boundary before policy and are deep-frozen; + * call identity and the registry-assigned {@link token} are readonly. An + * around-dispatch wrapper may set, replace, or remove `signal`. The registry + * freezes the complete object before `tools/result` observers run. + */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken @@ -145,10 +218,19 @@ interface ToolExecution extends ToolExecutionInput { A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. ```ts type-equiv +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ type ToolGuard = (execution: Readonly) => string | undefined ``` ```ts type-equiv +/** The outcome of one tool call. */ interface ToolExecutionResult { content: ContentBlock[] isError: boolean @@ -159,14 +241,8 @@ interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing contexts deferred by a composite tool or attached by - * `tools/post-execute` listeners for the NEXT request. They are not part of - * this call's `content`: the loop accepts them into the active-batch FIFO and - * appends them after every recorded `tool/result` when the batch settles, even - * when execution is interrupted. The array preserves each context's source, - * envelope, metadata, and production order. An accepted outer call keeps - * deferred contexts before decision contexts; a block retains only contexts - * supplied by the blocking decision. + * Model-facing context for the next request, separate from this tool result. The loop + * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. */ additionalContexts?: HookContext[] /** @@ -186,6 +262,12 @@ The registry materializes and freezes the final accepted result immediately befo Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: ```ts type-equiv +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; + * `ask` runs only after an approval service returns `allowed-once` and otherwise + * denies. Input rewriting is excluded because arguments are already logged and + * presented. + */ type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } @@ -193,6 +275,10 @@ type PreToolDecision = ``` ```ts type-equiv +/** + * Post-dispatch decision: accept or replace content, attach context for the next + * request, or block by turning corrective feedback into an error result. + */ type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } @@ -207,25 +293,41 @@ Post-policy may replace content; a block becomes an `isError` result containing The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily. ```ts type-equiv +/** The scalar values `enum`/`const` may carry (finite numbers only). */ type StructuredScalar = string | number | boolean | null ``` ```ts type-equiv +/** The `type` keywords the subset accepts. */ type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv +/** + * One node of the structured-output schema subset. Recursive via `properties` + * and `items`; see the module doc for the exact keyword semantics. + */ interface StructuredSchemaNode { type: StructuredSchemaType + /** Nested property schemas (`type: 'object'` only). */ properties?: Record + /** Required property names; each must appear in `properties`. */ required?: string[] + /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ additionalProperties?: boolean + /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ items?: StructuredSchemaNode + /** Allowed values (scalar types only). */ enum?: StructuredScalar[] + /** The single allowed value (scalar types only). */ const?: StructuredScalar + /** Annotation, ignored for validation. */ description?: string + /** Annotation, ignored for validation. */ title?: string + /** Annotation, ignored for validation (must still be JSON data). */ default?: unknown + /** Annotation, ignored for validation (must still be JSON data). */ examples?: unknown } ``` @@ -233,6 +335,7 @@ interface StructuredSchemaNode { A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire): ```ts type-equiv +/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } ``` diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 4edc415039..dcca6355e9 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` selects keyboard-driven `dsh-tui` overlays or `dsh-stdio` readline prompts, and `dsh-acp` maps questions to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) @@ -9,6 +9,7 @@ Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-int `AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. ```ts type-equiv +/** One selectable answer offered to the user. */ interface AskUserQuestionOption { /** User-facing label. */ label: string @@ -22,6 +23,7 @@ interface AskUserQuestionOption { `AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. ```ts type-equiv +/** One question in an ask_user_question request. */ interface AskUserQuestionItem { /** Stable model-provided question id, echoed in the answer. */ id: string @@ -41,6 +43,7 @@ interface AskUserQuestionItem { `AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. ```ts type-equiv +/** Request for a human answer. */ interface AskUserQuestionRequest { /** Questions to display. */ questions: AskUserQuestionItem[] @@ -56,6 +59,7 @@ interface AskUserQuestionRequest { Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. ```ts type-equiv +/** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string @@ -67,6 +71,7 @@ interface AskUserQuestionAnswerItem { ``` ```ts type-equiv +/** The human's answer. */ interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] @@ -78,6 +83,7 @@ interface AskUserQuestionAnswer { Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI. ```ts type-equiv +/** UI-side provider for user questions. */ interface UserInteractionProvider { ask(request: AskUserQuestionRequest): Promise } @@ -88,6 +94,7 @@ interface UserInteractionProvider { `UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation. ```ts type-equiv +/** Stable error taxonomy for user-interaction failures. */ class UserInteractionError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 9d79cd96c8..af14f54466 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -13,20 +13,37 @@ Search and fetch share no request schema and no business logic, but they are del The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. ```ts type-equiv +/** + * What one search-capable backend can return. The model-facing argument is just + * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged + * and enforced on the way back by the seam (see {@link WebSearchResult}). + */ interface WebSearchRequest { readonly query: string /** * Upper bound on returned sources; the seam truncates to it. Omitted = no - * bound. `dsh-tool-web` always sets it. + * bound. `dsh-tool-web` always sets it. A provider whose API supports a + * result-count control (Exa's `numResults`) should apply it at the request + * layer as a cost/latency optimization; the seam enforces the bound + * regardless. */ readonly maxResults?: number } ``` ```ts type-equiv +/** + * Normalized search outcome. `content` is optional provider-generated answer + * text or summary (Exa returns none; Perplexity returns a generated answer). + * `sources[]` is the portable citation surface. `truncated` is set by the seam + * when it cut `sources[]` down to `maxResults`. + */ interface WebSearchResult { + /** Optional provider-generated answer text, search context, or summary. */ readonly content?: string + /** Citeable sources, already truncated to the request's `maxResults`. */ readonly sources: readonly WebSearchSource[] + /** True when the seam dropped sources to honor `maxResults`. */ readonly truncated: boolean } ``` @@ -34,10 +51,17 @@ interface WebSearchResult { `content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. ```ts type-equiv +/** + * One citeable source. A source always has a URL; `title`, `snippet`, and + * `publishedAt` are optional because not every provider returns them — forcing + * adapters to invent them would make the seam lie (Perplexity citations may be + * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + */ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ readonly publishedAt?: string } ``` @@ -45,6 +69,12 @@ interface WebSearchSource { ## Fetch request and result ```ts type-equiv +/** + * What one fetch-capable backend is asked to retrieve. The request deliberately + * omits timeout, format, prompt, and extraction controls: cancellation is a + * direct execution argument, while presentation and higher-level LLM concerns + * belong outside safe retrieval. + */ interface WebFetchRequest { readonly url: string } @@ -53,10 +83,20 @@ interface WebFetchRequest { HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource. ```ts type-equiv +/** + * Normalized fetch outcome. A successful network fetch of a non-2xx response is + * a result, not an error: the status code is part of the fetched resource + * state. {@link WebError} is reserved for failures to safely retrieve or + * represent the resource. + */ interface WebFetchResult { + /** The final URL after allowed redirects (the request URL is in the request). */ readonly url: string + /** HTTP status code of the fetched response. */ readonly statusCode: number + /** Decoded body, classified by content kind. */ readonly body: WebFetchBody + /** True when the provider capped the decoded body. */ readonly truncated: boolean } ``` @@ -64,6 +104,15 @@ interface WebFetchResult { `WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`). ```ts type-equiv +/** + * The decoded body of a fetched resource. A CLOSED discriminated union owned by + * `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a + * new kind is a coordinated change across known packages, not a plugin + * extension. Consumers `switch` on `kind` ending in `default: assertNever(...)` + * so adding a kind breaks compilation at every consumer until handled. Each arm + * stays its own object literal even where fields coincide today, leaving room + * for arm-specific fields later (a `pdf` body's `pageCount`). + */ type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 4354105e70..40b17a3112 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -11,11 +11,24 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv +/** + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON DATA by the seam contract (the tool builds both from the model's + * schema-validated call; the engine validates `meta`'s shape and rejects loud + * before anything runs) — an engine never evaluates script text to obtain + * them. `parent` is REQUIRED — every `agent()` the script spawns is + * attributed to it (cwd, lineage, depth flow through the subagent seam). + */ interface WorkflowStartRequest { + /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta + /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent + /** Cancels the run when aborted (the tool's `exec.signal`). */ signal?: AbortSignal } ``` @@ -25,10 +38,21 @@ interface WorkflowStartRequest { The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. ```ts type-equiv +/** + * The script's identity block, provided as plain JSON data alongside the + * script body (the model-facing tool carries it as its `meta` parameter) and + * validated by the engine before the body runs. `name`/`description` are + * required; the rest is optional annotation. The field vocabulary matches the + * Claude Code dynamic-workflows meta block. + */ interface WorkflowMeta { + /** Short kebab-case workflow name (display + persistence key). */ name: string + /** One-line description of what the workflow does. */ description: string + /** Optional guidance on when this workflow applies (shown in listings). */ whenToUse?: string + /** Optional phase declarations matched by `phase()` calls. */ phases?: WorkflowPhase[] } ``` @@ -38,10 +62,27 @@ interface WorkflowMeta { The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script's materialized return value — plain host-realm JSON data (`null` when the script returned nothing) — meaningful only for `completed`. `stopReason` is a CLOSED union (engine-owned; consumers may exhaust it): `completed` | `cancelled` | `error`. A non-`completed` reason carries the failure in `error`, and the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv +/** + * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * the script's materialized return value (plain host-realm JSON data; `null` + * when the script returned `undefined`) — meaningful only for `completed`. + * A non-`completed` reason carries the failure in `error`; the consumer maps + * it to an `isError` tool result rather than reporting partial output. + */ interface WorkflowResult { + /** The script's return value (host JSON data; `null` for no return). */ value: unknown + /** Why the run settled. */ stopReason: WorkflowStopReason + /** The failure message (present iff `stopReason` is not `completed`). */ error?: string + /** + * How many `agent()` calls the run accepted over its whole lifetime. On a + * graceful settlement this is the script-side count (calls still queued for + * a concurrency slot included); on a termination path (grace force-settle, + * worker death) it degrades to the host-observed count — calls queued + * inside a terminated script are unknowable then. + */ agentsStarted: number } ``` @@ -51,11 +92,20 @@ interface WorkflowResult { The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine force-settles `cancelled`; the worker-thread engine then terminates the script's worker), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence; it never hangs on a stuck script. ```ts type-equiv +/** + * Holder-owned live workflow. `result` never rejects and settles within the + * engine's cancellation grace; failures resolve through `stopReason`. Consumers + * may cancel and must call idempotent `dispose()` on every path to await bounded + * script settlement and child quiescence. + */ interface WorkflowRun { readonly id: WorkflowRunId + /** The validated meta block (available before the body runs). */ readonly meta: WorkflowMeta readonly result: Promise + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ cancel(reason?: string): void + /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ dispose(): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 494eb09922..d7f82d7e92 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: b3c338f03f548b4de4b676850732731323d611f1 -development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c +development.md: a3268164cd06fb8bf66f52391bc42c2dc3ca9396 +development.zh.md: f8f29c64aa6e49e3ed6d7ef12df2a4911c9182b0 diff --git a/docs/development.md b/docs/development.md index b3c338f03f..a3268164cd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,12 +109,24 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The REPL agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The repl-agent demo uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:repl ``` +The full-screen TUI reuses the repl-agent composition through the pi-tui front door and needs the same credentials: + +```sh +pnpm run demo:tui +``` + +The self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials: + +```sh +pnpm run demo:cordis +``` + The ACP server agent demo exposes the agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: ```sh @@ -133,13 +145,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ## Documenting types verbatim (`ts type-equiv`) -The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 20f5c585dd..f8f29c64aa 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -109,12 +109,24 @@ echo 演示不需要 API 凭证: pnpm run demo:echo ``` -REPL agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: +repl-agent 示例使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:repl ``` +全屏 TUI 通过 pi-tui 前端复用 repl-agent 组装,并需要相同的凭证: + +```sh +pnpm run demo:tui +``` + +自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证: + +```sh +pnpm run demo:cordis +``` + ACP 服务器 agent 演示通过 JSON-RPC stdio 暴露 agent,同样需要 `DEEPSEEK_API_KEY`: ```sh @@ -133,13 +145,13 @@ pnpm run demo:acp ## 逐字记录类型(`ts type-equiv`) -[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index acd90d4423..4f6e6daff0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,32 +7,35 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:182`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:254`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:195`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:147`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:311`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:214`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:175`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:241`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:165`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:40`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | @@ -52,5 +55,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | [`invariants`](../packages/support/invariants) | +| `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 60de01ef81..0e6401fdc0 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -13,7 +13,9 @@ The process decision behind this index is recorded in [the documentation graph R | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | -| [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` | +| [repl-agent app composition](../examples/repl-agent/composition.md) | `hybrid generated` | +| [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | +| [headless-agent app composition](../examples/headless-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 8bc15d6e8e..cd87c161de 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Good: `A green gate does not mean the translation is correct.` ### Code block comments — never translate -- Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)` -- Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)` -- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical) +- Source code block contains: `# readline coding agent (needs DEEPSEEK_API_KEY)` +- Bad: `# readline 编码 agent(需要 DEEPSEEK_API_KEY)` +- Good: `# readline coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) ### Language switcher — English to Chinese - Source: `English | [中文](README.zh.md)` diff --git a/docs/module-graph.md b/docs/module-graph.md index 240772529b..e99a53c030 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -100,7 +100,6 @@ flowchart TD pkg_invariants["invariants"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] - pkg_subagent_mock["subagent-mock"] end subgraph group_ui["packages/ui"] pkg_acp["acp"] @@ -109,6 +108,7 @@ flowchart TD pkg_permission["permission"] pkg_stdio["stdio"] pkg_tool_ask_user["tool-ask-user"] + pkg_tui["tui"] pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end @@ -123,6 +123,7 @@ flowchart TD subgraph group_examples["packages/examples"] pkg_acp_demo["acp-demo"] pkg_agent_spine_demo["agent-spine-demo"] + pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] pkg_stdio_demo["stdio-demo"] end @@ -232,6 +233,7 @@ flowchart TD pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm @@ -246,10 +248,6 @@ flowchart TD pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval - pkg_stdio --> pkg_agent - pkg_stdio --> pkg_llm - pkg_stdio --> pkg_session - pkg_stdio --> pkg_user_interaction pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -284,8 +282,10 @@ flowchart TD pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent + pkg_subagent --> pkg_brand pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -350,6 +350,7 @@ flowchart TD pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent @@ -370,14 +371,23 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools - pkg_subagent_mock --> pkg_agent - pkg_subagent_mock --> pkg_llm - pkg_subagent_mock --> pkg_subagent pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_stdio --> pkg_agent + pkg_stdio --> pkg_agent_loop + pkg_stdio --> pkg_llm + pkg_stdio --> pkg_session + pkg_stdio --> pkg_user_interaction + pkg_tui --> pkg_agent + pkg_tui --> pkg_agent_loop + pkg_tui --> pkg_llm + pkg_tui --> pkg_session + pkg_tui --> pkg_tools + pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_home @@ -413,7 +423,16 @@ flowchart TD pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction pkg_acp_demo --> pkg_workspace_context + pkg_cli_demo --> pkg_agent + pkg_cli_demo --> pkg_agent_spine_demo + pkg_cli_demo --> pkg_app_boot + pkg_cli_demo --> pkg_llm + pkg_cli_demo --> pkg_session + pkg_cli_demo --> pkg_session_persistence_jsonl + pkg_cli_demo --> pkg_tools + pkg_cli_demo --> pkg_workspace_context pkg_stdio_demo --> pkg_agent + pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot pkg_stdio_demo --> pkg_llm @@ -422,6 +441,7 @@ flowchart TD pkg_stdio_demo --> pkg_stdio pkg_stdio_demo --> pkg_tool_ask_user pkg_stdio_demo --> pkg_tools + pkg_stdio_demo --> pkg_tui pkg_stdio_demo --> pkg_user_interaction pkg_stdio_demo --> pkg_workspace_context ``` @@ -480,17 +500,16 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -505,15 +524,17 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 5484d0efe2..3bc15253a5 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1,13 +1,85 @@ -# Persistence Log Event Catalog +# Session Persistence Event Catalog -Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). +Every event type that can appear in a session's durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit). -This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md). +This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md). -The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. +The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. + +## Event envelope + +```ts persistence-catalog +/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */ +export type SessionEventType = keyof SessionEventMap + +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +export type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'context/message' + | 'steering/message' + +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +export type SurfaceOp = + | 'append' + | { op: 'replace'; start: number; end: number } + +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ +export type SessionEvent = { + [K in SessionEventType]: { + type: K + /** Monotonic sequence number within the session. */ + seq: number + /** Unix epoch milliseconds. */ + time: number + data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { + /** + * Seq numbers of events that are provenance sources of this event + * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. + */ + sourceEventSeqs?: number[] + /** How this event entered the surface; absent for non-surface events. */ + surfaceOp?: SurfaceOp + } : object) +}[T] +``` + +Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) ## Events @@ -15,10 +87,21 @@ The on-disk envelope around every payload is `SessionEvent` — `type`, monotoni #### `approval/asked` — log-only -An approval question was put to the answerer chain — log-only audit (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs it with the `approval/decided` that always follows; `toolName` is the tool the question is about, `callId` the exact tool call when the asker had one, `reason` the asker's human-readable explanation (e.g. a hook's permission-decision reason). - ```ts persistence-catalog -'approval/asked': { id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string } +/** + * An approval question was put to the answerer chain — log-only audit + * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs + * it with the `approval/decided` that always follows; `toolName` is the + * tool the question is about, `callId` the exact tool call when the asker + * had one, `reason` the asker's human-readable explanation (e.g. a hook's + * permission-decision reason). + */ +'approval/asked': { + id: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string +} ``` Types: [CallId](core-data-structures/core.md) @@ -27,19 +110,31 @@ Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approv #### `approval/decided` — log-only -The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly one per ask, appended when the outcome is known: a decision, a cancellation, or the fail-closed `'unavailable'`. - ```ts persistence-catalog -'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } +/** + * The outcome of a prior `approval/asked` (same `id`) — log-only audit. + * Exactly one per ask, appended when the outcome is known: a decision, a + * cancellation, or the fail-closed `'unavailable'`. + */ +'approval/decided': { + id: ApprovalRequestId + outcome: ApprovalOutcome +} ``` Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only -The session's approval policy was switched — log-only, durable, replayable, never in the model transcript (the model learns the policy from the prompt section and the narrator's notices). The LAST such event is the session's override (effectiveApprovalPolicy); who asked for it is derivable from position (an event after the log's last `request/header` was a runtime switch by the user). - ```ts persistence-catalog +/** + * The session's approval policy was switched — log-only, durable, + * replayable, never in the model transcript (the model learns the policy + * from the prompt section and the narrator's notices). The LAST such + * event is the session's override ({@link effectiveApprovalPolicy}); + * who asked for it is derivable from position (an event after the log's + * last `request/header` was a runtime switch by the user). + */ 'approval/policy': { policy: ApprovalPolicy } ``` @@ -49,9 +144,8 @@ Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approv #### `assistant/chunk` — log-only -Raw stream chunk — token-level replay fidelity. - ```ts persistence-catalog +/** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } ``` @@ -61,9 +155,13 @@ Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/ #### `assistant/message` — surface -Assembled assistant message for one step (derived history uses this). Carries the step's `usage` when the adapter reported token accounting, so the model output and its accounting travel together (there is no separate usage record). `usage` is absent when the adapter reported none. - ```ts persistence-catalog +/** + * Assembled assistant message for one step (derived history uses this). + * Carries the step's `usage` when the adapter reported token accounting, so + * the model output and its accounting travel together (there is no separate + * usage record). `usage` is absent when the adapter reported none. + */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } ``` @@ -75,9 +173,12 @@ Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/ #### `bash/sandbox-mode` — log-only -Durable log-only sandbox-mode override; never a surface event or model message. Execution and ACP option reporting fold the latest event through effectiveSandboxMode without adding a prompt notice. - ```ts persistence-catalog +/** + * Durable log-only sandbox-mode override; never a surface event or model + * message. Execution and ACP option reporting fold the latest event through + * {@link effectiveSandboxMode} without adding a prompt notice. + */ 'bash/sandbox-mode': { mode: SandboxMode } ``` @@ -87,9 +188,8 @@ Source: [`packages/bash/bash/src/session-mode.ts:20`](../packages/bash/bash/src/ #### `compact/end` — log-only -Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. - ```ts persistence-catalog +/** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ 'compact/end': { turn: number; error?: string } ``` @@ -97,9 +197,8 @@ Source: [`packages/compact/compact/src/types.ts:40`](../packages/compact/compact #### `compact/start` — log-only -Marks the start of a compaction — log-only, holds the lock until `compact/end`. - ```ts persistence-catalog +/** Marks the start of a compaction — log-only, holds the lock until `compact/end`. */ 'compact/start': { turn: number } ``` @@ -107,10 +206,30 @@ Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact #### `compact/summary` — log-only -Provenance record of a completed summarization — log-only, no surfaceOp. The summary content is in `data.summary`; the actual surface replacement is performed by a subsequent `user/message` event that shadows the compacted range. - ```ts persistence-catalog -'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number; provider: string; model: string; maxTokens?: number } +/** + * Provenance record of a completed summarization — log-only, no surfaceOp. + * The summary content is in `data.summary`; the actual surface replacement + * is performed by a subsequent `user/message` event that shadows the + * compacted range. + */ +'compact/summary': { + summary: ContentBlock[] + shadowedRange: { start: number; end: number } + shadowedSeqs: number[] + shadowedTokenCount: number + /** The provider route that wrote the summary. */ + provider: string + /** + * The model that wrote the summary — the summarize call's envelope, + * reported by the backend that made the call, logged so the one-shot + * request is reconstructable from log + code and "which model wrote + * this summary" has a durable answer (the reconstructability RFC). + */ + model: string + /** The generation cap the summarize call sent, when one applied. */ + maxTokens?: number +} ``` Types: [ContentBlock](core-data-structures/core.md) @@ -121,10 +240,20 @@ Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact #### `context/message` — surface -In-session context injection (file-change notices, subdir AGENTS.md, skill content, cron notifications, …). Rendered into the derived history as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller own the complete model-facing frame; `meta` is durable JSON state omitted from the model projection. - ```ts persistence-catalog -'context/message': { content: ContentBlock[]; source: MessageSource; envelope?: ContextEnvelope; meta?: JsonValue } +/** + * In-session context injection (file-change notices, subdir AGENTS.md, + * skill content, cron notifications, …). Rendered into the derived history + * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller + * own the complete model-facing frame; `meta` is durable JSON state omitted + * from the model projection. + */ +'context/message': { + content: ContentBlock[] + source: MessageSource + envelope?: ContextEnvelope + meta?: JsonValue +} ``` Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) @@ -135,20 +264,44 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/ #### `hook/invoked` — log-only -A hook command was invoked at a hook point — log-only provenance (like `compact/*`; NOT a SurfaceEventType, carries no `surfaceOp`). `dialect` is the bridge that ran it (`claude`/`codex`), `point` the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group pattern that selected it (absent for match-all), `handlerId` a stable id for the command (so an invoked/result pair correlates). `turn` is the open turn the invocation lives inside. - ```ts persistence-catalog -'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string } +/** + * A hook command was invoked at a hook point — log-only provenance (like + * `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`). + * `dialect` is the bridge that ran it (`claude`/`codex`), `point` + * the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group + * pattern that selected it (absent for match-all), `handlerId` a stable id + * for the command (so an invoked/result pair correlates). `turn` is the open + * turn the invocation lives inside. + */ +'hook/invoked': { + turn: number + point: string + dialect: HookDialect + matcher?: string + handlerId: string +} ``` Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-protocol/src/types.ts) #### `hook/result` — log-only -Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime. - ```ts persistence-catalog -'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number } +/** + * Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the + * parsed permission result, `stop` for `continue:false`, or `pass`; exit code + * may be absent, stderr is bounded, and duration is wall-clock runtime. + */ +'hook/result': { + turn: number + point: string + handlerId: string + decision: string + exitCode?: number + stderrSummary?: string + durationMs: number +} ``` Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) @@ -157,9 +310,13 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook- #### `permission/preset` — log-only -Records the selected preset as durable, log-only user intent. The knob events follow in the same turn and control execution; this event stays out of the model transcript and lets effectivePermissionPreset preserve a selection when bundles match. - ```ts persistence-catalog +/** + * Records the selected preset as durable, log-only user intent. The knob + * events follow in the same turn and control execution; this event stays + * out of the model transcript and lets {@link effectivePermissionPreset} + * preserve a selection when bundles match. + */ 'permission/preset': { preset: string } ``` @@ -169,9 +326,11 @@ Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src #### `prompt/blocked` — log-only -Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. - ```ts persistence-catalog +/** + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. + */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } ``` @@ -183,9 +342,11 @@ Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/ #### `request/header` — log-only -Full header for the next request, appended inside its step before dispatch. It is log-only; the latest snapshot reconstructs the request header. - ```ts persistence-catalog +/** + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. + */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` @@ -195,9 +356,8 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ #### `steering/message` — surface -Steering content injected between steps of a running turn. - ```ts persistence-catalog +/** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } ``` @@ -209,9 +369,8 @@ Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/ #### `step/end` — log-only -Closes step `step` of turn `turn`. - ```ts persistence-catalog +/** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } ``` @@ -219,9 +378,8 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/ #### `step/start` — log-only -Opens step `step` of turn `turn` — one model call plus the tool executions it requested. - ```ts persistence-catalog +/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } ``` @@ -231,9 +389,8 @@ Source: [`packages/core/session/src/types.ts:195`](../packages/core/session/src/ #### `todo/write` — log-only -Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. - ```ts persistence-catalog +/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } ``` @@ -245,9 +402,12 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ #### `tool/call` — log-only -The model requested one tool invocation: `name` with the raw `arguments` JSON string exactly as the model produced it (unparsed). `callId` pairs the call with its `tool/result`. - ```ts persistence-catalog +/** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } ``` @@ -257,9 +417,22 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ #### `tool/code-dispatch` — log-only -One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`:code:`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Before bounding, occurrences of a non-root session workspace path are normalized to `.` so host-specific absolute path lengths cannot change the summary. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction. - ```ts persistence-catalog +/** + * One bridged sub-dispatch from a `run_code` program: the parent + * `run_code` call id, the deterministic sub-call id + * (`:code:`), the tool `name` with its JSON-normalized + * `arguments` — the exact value dispatched, normalized BEFORE dispatch, + * so this append can never fail on payload shape — whether the sub-call + * errored, and a bounded `resultSummary` of its model-facing text. Before + * bounding, occurrences of a non-root session workspace path are + * normalized to `.` so host-specific absolute path lengths cannot change + * the summary. + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains its queue before + * returning), so the turn-enclosure invariant holds by construction. + */ 'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string } ``` @@ -269,9 +442,16 @@ Source: [`packages/core/tools/src/code-mode.ts:34`](../packages/core/tools/src/c #### `tool/result` — surface -A completed tool call's model-facing result, plus an optional tool-private `meta` presentation payload. `meta` is opaque to the core (`unknown` — the producing tool owns its shape and reads it back in `presentResult`) but MUST be JSON-serializable: `Session.append` runtime-validates all event data with `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the durable log reproduces the identical card on replay. Absent unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). - ```ts persistence-catalog +/** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } ``` @@ -283,9 +463,12 @@ Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/ #### `turn/end` — log-only -Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awaited `session/flush` checkpoint at every turn end, so the turn boundary is also the durable-commit boundary. - ```ts persistence-catalog +/** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * fires the awaited `session/flush` checkpoint at every turn end, so the turn + * boundary is also the durable-commit boundary. + */ 'turn/end': { turn: number; reason: TurnEndReason } ``` @@ -295,9 +478,13 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ #### `turn/start` — log-only -Opens turn `turn`. `trigger` records what started it — a drained message batch or an idle-time injection. The turn is the durability/replay boundary: every event sits between a `turn/start` and its matching `turn/end` (the turn-enclosure invariant). - ```ts persistence-catalog +/** + * Opens turn `turn`. `trigger` records what started it — a drained message + * batch or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } ``` @@ -309,9 +496,8 @@ Source: [`packages/core/session/src/types.ts:187`](../packages/core/session/src/ #### `user/message` — surface -A user-visible prompt (queued message drained at turn start). - ```ts persistence-catalog +/** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } ``` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 20a5966ba4..46045265c6 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -21,8 +21,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| -| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | ### Architecture @@ -82,11 +82,13 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Bash-backed grep and glob discovery tools](implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md) | 2026-07-09 | | [Expose agent session identity and JSONL location to tools and hooks](implemented/feature/2026-07-10-agent-session-identity-and-log-location.md) | 2026-07-10 | +| [Parallel tool-call execution by per-call safety](implemented/feature/2026-07-10-parallel-tool-call-execution.md) | 2026-07-10 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | | [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 | | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | +| [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | ### Simplification @@ -99,6 +101,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Unify the agent id and the session id](implemented/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | @@ -114,6 +117,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | | [Simplify session-log representation](implemented/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Retire the standalone subagent mock package](implemented/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | +| [Use one surface manager per session](implemented/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture @@ -156,9 +161,11 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | | [Tool output spill policy](implemented/architecture/2026-07-08-tool-output-spill-files.md) | 2026-07-08 | +| [After-call compaction pressure and context-overflow recovery](implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) | 2026-07-10 | | [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | | [Provider-routed LLM adapters and a generic pi-ai backend](implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) | 2026-07-14 | +| [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 | | [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 | | [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 | @@ -209,6 +216,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | | [Pin request-header content in one snapshot scenario](implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md) | 2026-07-06 | | [Extract the ACP snapshot suite into a support package](implemented/testing/2026-07-08-shared-acp-snapshot-package.md) | 2026-07-08 | +| [Snapshot semantic terminal state for the TUI](implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) | 2026-07-18 | ## Rejected @@ -230,6 +238,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 | | [Collapse workflows to the exercised foreground core](rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md) | 2026-07-12 | | [Prune unused skill registry surface](rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md) | 2026-07-12 | +| [Fold the single compaction backend into its service package](rejected/simplification/2026-07-19-fold-compaction-package-split.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 1c22f9b7ca..77f521834c 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -23,4 +23,4 @@ In-session context injection (`context/message`, `steering/message`) renders as - Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFCs. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md index 8293924d37..1d10bfa479 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md @@ -10,8 +10,8 @@ The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes: -- **waterfall** (around-middleware) where plugins transform, veto, or wrap: `agent/prompt-submit`, `agent/request`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. -- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. +- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/prompt-submit`, `agent/request`, `agent/request-error`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`. +- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` and `agent/post-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final. - **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint. - **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 327aa8eac3..3bd3c3fdf1 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -21,7 +21,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 665063399d..d75ec379fc 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,28 +12,30 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker. +A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. ### 2. `AgentHandle` async disposer -`ctx.agents.create`/`resume` and `AgentFactory` return `AgentHandle = { agent, dispose() }`. Disposal is a consumer capability; an observer holding only `Agent` cannot tear it down. The caller fiber and factory provider also own the instance, and every path shares one memoized teardown: stop the loop, await quiescence and flushes, detach the agent and session, then unwind its scope. IDs become reusable when their registry entries detach. Config-created agents belong to the loop fiber; ACP stores and disposes each session handle. +`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw). -Teardown order is load-bearing for durability. The session lifecycle and loop share one composite Cordis effect so LIFO disposal stops the loop and awaits `agent.done` before detaching the session. Sibling effects would dispose concurrently and could remove append hooks before the closing flush. Disposal notifications are contained so they cannot interrupt the chain. +**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown. ### 3. Bash owner token in the seam -Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped. +Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.id` (the shared registry/session id) as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) ## Verification -- ACP disconnect or session close leaves no registered agent or session-store entry, including when `session/load` races teardown. -- Cancelling before a queued prompt starts prevents that prompt from running or absorbing the next prompt. -- Reloading `dsh-tool-bash` does not let another session read or kill an existing background task because ownership remains on the executor. -- Config-created agents remain loop-fiber-owned, so non-ACP demos need not manage handles explicitly. +These invariants hold and are pinned by tests: + +- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown. +- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. +- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). +- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. ## Session owner tokens are unique among live agents -The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it. +The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 7c6c5b00d0..d639fe2917 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -31,7 +31,7 @@ export type SurfaceOp = ### SurfaceManager: delta-based, not full rebuild -A `SurfaceManager` class (private to `Session`) maintains one ordered `number[]` of event seqs. It tracks `_lastProcessedSeq` and processes only the new events since the last access rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial suffix folded on first access. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no link objects or seq-to-node map duplicate the order. +A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order. Delta processing is O(1) when no new events and O(new events) when new events arrive. @@ -60,7 +60,7 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d ## Consequences -- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array; `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). +- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). - **`packages/support/invariants`**: Surface-related validation rules. diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 9f83e46b24..fd5c5bf953 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -4,23 +4,23 @@ Status: implemented ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded IDs in the bash seam.** `BashTask.id` and every executor/tool boundary used bare `string`, even though the generated value has the same `name-N` shape as default session ids. The model also returns this value through `task_id`, so confusing task and session ids was both type-correct and reachable. +**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). -**Gap 2 — erosion of existing brands.** `CallId`, `SessionId`, and `AgentId` became bare strings in registry maps, public lookup parameters, ACP session tracking, and the persistence coordinator. Dropping a brand at a lookup boundary defeats its main protection. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -44,7 +44,7 @@ export function OwnerToken(id: string): OwnerToken { ### Why not typing `owner` as `SessionId`? -The executor treats ownership as opaque and must not depend on the session model. A distinct `OwnerToken` preserves that boundary while preventing raw strings or task ids from being passed as owners. `dsh-tool-bash`, which owns the access policy, performs the single conversion from `SessionId`. +The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. ## Out of scope / possible extensions @@ -58,10 +58,10 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -`BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded through the executor, local implementation, and model-facing tool without adding a `dsh-session` dependency. Collections, public parameters, and exported signatures use the applicable brand for `CallId`, `SessionId`, `AgentId`, or `BashTaskId` rather than bare `string`; raw provider, ACP, and model inputs enter through the brand factory instead of scattered casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. ## Consequences -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above. - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 4cf055179c..b38777be38 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -24,7 +24,7 @@ This vocabulary is the foundation for interception decisions, the durable `hook/ **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 854807c109..375949a1e8 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -8,9 +8,9 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. -**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are coding-agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. +**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. **The fork tool's description was false.** `dsh-tool-subagent` hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the `subagent_fork` instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: `PromptSection.name` was documented "(diagnostics / dedup)" but duplicates were silently accepted. @@ -30,7 +30,7 @@ Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, prov ### Persona as the order-0 section -`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership @@ -43,7 +43,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Alternatives considered - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) -- **Inject the model name via the `agent/request` waterfall** — prompt text composed in two places, and `agent/pre-step`'s `fullSystemPrompt` would omit it, so compaction would measure a prompt that is not what the model sees. +- **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this RFC cures. - **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. @@ -56,7 +56,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Shipped invariants -- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- The repl-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. - Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index a084ba47a4..9b53369086 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -12,7 +12,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant by the unfrozen-request marker. +**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant by the unfrozen-request marker. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. @@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro `EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. +Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance before the generic `agent/pre-step` checkpoint and boundary snapshot. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. -**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. +**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. **Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 30674f8c8b..a04040d540 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. +[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index dcff4d3220..15477e023a 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -60,8 +60,7 @@ The ordinary contributor pattern is to register the complete local world during ```js const handle = await ctx.agents.create({ - agentId: AgentId('reviewer'), - sessionId: SessionId('reviewer-session'), + sessionId: SessionId('reviewer'), agentOptions: { model: 'model-name' }, setup(agentCtx) { agentCtx.systemPrompt.section({ diff --git a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md index 3a60c5c223..bbca065d87 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -162,7 +162,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection. - `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`). - `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result. -- The `coding-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). +- The `repl-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml new file mode 100644 index 0000000000..c54a0b344d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: d88d7aaea8ccec30b10bfeb17f1312cfe87a0ce7 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 42e6114304de9c8022ef8f1341035858c0c7d9ec diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md new file mode 100644 index 0000000000..d88d7aaea8 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -0,0 +1,61 @@ +# RFC: After-call compaction pressure and context-overflow recovery + +Status: implemented + +English | [中文](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md) + +## Problem + +`agent/pre-step` runs before final request routing and before assistant output, tool results, buffered context, and steering exist. Even with the assembled prompt and session prefix, its pressure view is provisional because `agent/request` can still change routing or call configuration and tool schemas are not frozen with those inputs. Adding fields cannot make pre-call state describe a completed call and couples the generic seam to compaction. + +Successful calls are not the only pressure signal. A provider can reject a request for exceeding its context window before it returns usage, and some successful calls omit usage. The system therefore needs replayable post-call pressure plus a narrow failure-recovery path that preserves the provider error whenever compaction cannot prove useful progress. + +## Decision + +### Successful pressure moves to a durable post-step checkpoint + +`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields. + +The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A listener failure is an ordinary turn failure; it never enters model-request recovery. + +`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue with full history. + +### Request recovery is limited to the final model boundary + +`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures. + +The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. + +If cancellation lands after assistant tool calls are durable but before all calls dispatch, the loop records a synthetic `tool/call` and aborted `tool/result` pair for every undispatched call before following the normal abort path. The surface therefore never retains orphaned durable tool calls merely because cancellation won the race. + +### CompactService exposes intent, not token accounting + +`CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. + +For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. + +For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. + +`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. + +The default summarizer resolves explicit configuration, then the latest logged route, then agent options. Because direct `llm/stream` middleware may reroute that auxiliary call, `compact/summary.{provider, model}` records the final mutable `GenerateOptions` target observed after dispatch rather than the pre-waterfall candidate. + +## Testing + +Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, balanced overflow reduction, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through compaction to a reconstructed retry request. + +## Alternatives considered + +- **Keep provisional pre-step pressure and add more arguments** — rejected because later routing and request mutation remain outside any earlier snapshot, while generic lifecycle becomes coupled to one plugin. +- **Retry the same numbered step** — rejected because recovery appends durable events after the failed boundary. A new step preserves balanced nesting and reconstructability. +- **Retry whenever `compactIfNeeded` returns a result** — rejected because a custom backend can report success without changing model-visible state. `replaceGeneration` is the authoritative proof. +- **Let compact-basic parse provider wording** — rejected because classification belongs at adapters and must cover both thrown and in-band delivery. +- **Fall back to `AgentOptions.model` when no durable route exists** — rejected because automatic policy must describe a completed logged request. Headerless pressure and recovery delegate unchanged. + +## Consequences + +Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change. + +The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window or split one indivisible oversized message/tool unit. + +This RFC supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam RFC](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md new file mode 100644 index 0000000000..42e6114304 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -0,0 +1,61 @@ +# RFC:调用后压缩压力与上下文溢出恢复 + +Status: implemented + +[English](2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) | 中文 + +## 问题 + +`agent/pre-step` 运行在最终请求路由之前,也早于 assistant 输出、工具结果、缓冲上下文与 steering 的产生。即使它接收已装配提示词与会话前缀,压力视图仍是临时的,因为 `agent/request` 还可以改变路由或调用配置,工具 schema 也没有与这些输入一同冻结。增加字段无法让调用前状态描述已完成调用,还会把通用 seam 与压缩耦合。 + +成功调用也不是唯一的压力信号。提供方可能在返回 usage 之前就因上下文窗口超限拒绝请求,一些成功调用也不提供 usage。因此,系统需要可回放的调用后压力,以及一条狭窄的失败恢复路径;当压缩无法证明取得有效进展时,必须保留原始提供方错误。 + +## 决策 + +### 成功压力移动到持久 post-step 检查点 + +`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。 + +循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。监听器失败属于普通 turn 失败,绝不会进入模型请求恢复。 + +`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并继续使用完整历史。 + +### 请求恢复只覆盖最终模型边界 + +`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 + +恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 + +如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 + +### CompactService 暴露意图,而不拥有 token 核算 + +`CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 + +对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 + +对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 + +`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 + +默认摘要器依次解析显式配置、最近记录的路由与 agent options。因为直接 `llm/stream` 中间件可以重新路由该辅助调用,`compact/summary.{provider, model}` 记录分发后最终可变的 `GenerateOptions` 目标,而不是 waterfall 之前的候选值。 + +## 测试 + +单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、平衡溢出缩减、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证压缩后的重试请求从替换表层重建。 + +## 考虑过的替代方案 + +- **保留临时 pre-step 压力并增加更多参数**——不予采纳,因为后续路由与请求变换仍在更早快照之外,同时通用生命周期会耦合到单个插件。 +- **重试相同编号的 step**——不予采纳,因为恢复会在失败边界之后追加持久事件。新 step 保持边界配对与可重建性。 +- **只要 `compactIfNeeded` 返回结果就重试**——不予采纳,因为自定义后端可能报告成功却没有改变模型可见状态。`replaceGeneration` 才是权威证明。 +- **让 compact-basic 解析提供方措辞**——不予采纳,因为分类属于适配器,而且必须同时覆盖抛出式与带内交付。 +- **没有持久路由时回退到 `AgentOptions.model`**——不予采纳,因为自动策略必须描述已完成且已记录的请求。没有请求头的压力检查与恢复会原样委托。 + +## 后果 + +Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。 + +代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 + +本 RFC 只取代[压缩能力接缝 RFC](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml new file mode 100644 index 0000000000..52128ea344 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-15-agent-initiator-scope.md: a9df15beb8744216e020c259934db9fdf8b28b79 +2026-07-15-agent-initiator-scope.zh.md: 4198f066ef27042bda0d12fbcaf86f143482d596 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md new file mode 100644 index 0000000000..a9df15beb8 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -0,0 +1,65 @@ +# RFC: Initiating Agent scope over AsyncLocalStorage + +Status: implemented + +English | [中文](2026-07-15-agent-initiator-scope.zh.md) + +## Problem + +The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. + +Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context. + +## Decision + +The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the initiating Agent. It stores the exact `Agent` directly rather than introducing a one-field frame; a separate private run token records nested boundary lineage only for teardown bookkeeping and carries no identity. The [core-data catalog](../../../core-data-structures/core.md#initiating-agent) identifies the carried type. + +`currentInitiator()` reads optionally, `requireInitiator()` throws `no initiating agent is active`, and `withInitiator(agent, operation)` preserves the operation's exact synchronous value or Promise. `withoutInitiator(operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners. + +`AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup. + +Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. + +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. + +`AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering. + +Initiator scope does not own detached work: registry drain tracks only the Promise returned by `withInitiator()` or `withoutInitiator()`. Asynchronous resources created inside a boundary inherit its store until they settle or ALS is disabled, so their owning seam must stop unreturned work explicitly. Agent-owned foreground work returns its lifetime and keeps its cancellation contract. Unrelated timers, queues, and deployment infrastructure start under `withoutInitiator(operation)`; queue, worker, process, and wire boundaries serialize identity rather than expecting ALS propagation. + +A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. + +This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. + +## Verification + +Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, intrinsic Promise settlement observation, overlapping, nested, and cleared boundaries, restoration after throws or rejection, ordinary and reentrant drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, root teardown, and package-private loop and tool scheduling through the ambient lookup. Composition, module-graph, build, and runtime-closure checks keep `ctx.agents` wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider. + +A test-double host-aware transport derives `X-Harness-Session-Id` internally and verifies that tool schema and logged arguments contain no identity field. The service deliberately does not drain async work omitted from the Promise returned by the boundary operation; that work remains subject to its owner's explicit stop contract. + +## Alternatives considered + +**Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. + +**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising. + +**Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability. + +**Store a named or complete runtime frame.** A one-field `{ agent }` frame only wraps the value, while Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Adding more fields would create stale snapshots and another lifecycle; carrying `Agent` directly keeps the boundary named by its methods without duplicating state. + +**Include a step `AbortSignal`, `cwd`, sandbox, or authorization.** Their lifetimes and authority do not match the driver boundary, and their existing seams already pass them explicitly. Adding a control capability requires a separate decision and nested lifecycle contract. + +**Use a process-global `currentAgent`.** Concurrent Agents and subagents overwrite one another across awaited continuations, so a mutable global is correct only under a serialization guarantee the harness does not make. + +**Derive identity from model-visible arguments.** Model or user input cannot be trusted to select Session, tenant, or sandbox routing. + +**Add routing identity to every capability seam.** That spreads hosting concerns through provider-neutral APIs. A host-aware implementation owns its transport header while public boundaries remain explicit. + +## Consequences + +Deep infrastructure gains one trusted process-local initiating Agent without widening existing tool and capability requests. Concurrent and nested drivers isolate automatically, AgentLoop gains no additional mandatory service, and HMR/root disposal reaches quiescence before ALS is disabled. + +The dependency is implicit in function signatures and carries a capability-bearing Agent object. Consumers must restrict it to cross-cutting infrastructure, treat ambient presence as neither liveness nor authorization, and retain explicit cancellation and ownership checks. ALS also has an always-on propagation cost and does not cross worker, process, HTTP, or durable queue boundaries. + +The teardown design deliberately accepts Node's [Stability 1 (Experimental)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) `AsyncLocalStorage.disable()` dependency. Node requires `disable()` before an ALS instance can be garbage-collected, which matters when HMR replaces AgentRegistry-owned instances; the service state guard prevents a later boundary from re-entering the instance after disposal. + +The scope deliberately carries only the Agent, omitting turn, step, `signal`, `cwd`, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md new file mode 100644 index 0000000000..4198f066ef --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -0,0 +1,65 @@ +# RFC: 基于 AsyncLocalStorage 的发起 Agent 作用域 + +Status: implemented + +[English](2026-07-15-agent-initiator-scope.md) | 中文 + +## 问题 + +Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 + +进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。 + +## 决策 + +必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../core-data-structures/core.md#initiating-agent)标明了所携带的类型。 + +`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 + +`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。 + +因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 + +隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 + +`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose 后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 + +发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 `withInitiator()` 或 `withoutInitiator()` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所有前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `withoutInitiator(operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 + +宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 + +本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 + +## 验证 + +Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 + +测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 + +## 考虑过的替代方案 + +**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 + +**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 + +**新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。 + +**保存命名帧或完整运行时帧。** 只有一个字段的 `{ agent }` 帧只是包装该值,而 Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。增加更多字段会产生陈旧快照和另一套生命周期;直接携带 `Agent`,由方法名标识边界,无需重复保存状态。 + +**包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。 + +**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。 + +**从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。 + +**给每个能力 seam 增加路由身份。** 这会把宿主关注点扩散到提供方无关 API。宿主感知实现拥有其传输请求头,而公开边界继续显式传递身份。 + +## 后果 + +深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,AgentLoop 不增加新的必需服务,HMR 或根 Context dispose 会在禁用 ALS 前完成排空。 + +该依赖不会出现在函数签名中,并且携带一个具有控制能力的 Agent 对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 + +该销毁设计有意依赖 Node 的 [Stability 1(实验性)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) API `AsyncLocalStorage.disable()`。Node 要求在 ALS 实例可被垃圾回收前调用 `disable()`,这对 HMR 替换 AgentRegistry 所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续边界重新进入该实例。 + +该作用域有意只携带 Agent,省略轮次、步骤、`signal`、`cwd`、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index e1d17fef0b..3c1e0a2573 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-replay-token-meter-service.md: 34df0383d1b8ae8047c4283eef3800de772c3cae -2026-07-15-replay-token-meter-service.zh.md: 51f319f3c473fe247791e69133eef4280b768002 +2026-07-15-replay-token-meter-service.md: a86696a1077c9fa21d9984280009f9a0df0b4bbb +2026-07-15-replay-token-meter-service.zh.md: a6803fc9921a832b7614a7dc26b545a9145a08df diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md index 34df0383d1..a86696a107 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -30,17 +30,17 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas ### Compact-basic consumes, but does not own, measurement -`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. The backend is factored into configuration, automatic triggering, region transaction, and summarizer modules, while `summarize()` remains its sole subclass hook. The singleton service consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. +`dsh-compact-basic` requires `ctx.tokenMeter`; `CompactService` gains no token methods or types. Configuration, the region transaction, and summarization stay in separate modules; the service registers automatic listeners itself, while `summarize()` remains its sole subclass hook. The singleton meter consistently prices pressure, retention, shadowed content, provenance, and non-shrinking-summary rejection. Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. -Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model, maximum summary output `8192`, one extra compaction attempt, and automatic triggering enabled. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. `summarizationProvider` and `summarizationModel` must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. +Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. -The pre-step trigger measures a provisional envelope: the current prompt and prefix override logged values, while the latest logged header supplies provider, model, tools, and other call config. A router-only agent without a complete provider/model pair skips that provisional check because `agent/request` can route later; any routed target can use the singleton estimator. +Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. ## Testing -Unit coverage pins service configuration, fixed estimation, envelope invalidation, latest-anchor replacement across provider/model switches, usage and missing-usage paths, seeded append/replace replay, signed deltas, provenance modes, malformed boundaries, unified snapshot detachment and deep immutability, surface-total equality, listener ordering, reload, compact defaults, routing fallback, one-call automatic decisions, retention, convergence, and log-revision rollback. A real Loader/Include YAML fixture loads the exact zero-config token-meter and compact-basic package names in dependency order. +Unit tests cover fixed estimation, envelope invalidation and anchor replacement, replay boundaries, immutable snapshots, routed pressure, convergence, overflow generation proof, and rollback. A real Loader/Include fixture verifies the zero-config token-meter and compact-basic load path in dependency order. ## Alternatives considered @@ -57,4 +57,4 @@ Unit coverage pins service configuration, fixed estimation, envelope invalidatio - Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. - Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. -- The pre-step compact integration can skip a router-only first check and can miss tool or routing changes applied later in request middleware. +- Post-step pressure reads the exact logged routing/tools/prefix boundary; provider overflow classification remains the adapter-maintained backstop for requests rejected before a successful usage anchor. diff --git a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 51f319f3c4..a6803fc992 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -30,17 +30,17 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket ### compact-basic 消费计量,但不拥有计量 -`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。后端拆分为配置、自动触发、区域事务与摘要器模块,而 `summarize()` 仍是唯一的子类 hook。单例服务一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 +`dsh-compact-basic` 要求 `ctx.tokenMeter`;`CompactService` 不增加 token 方法或类型。配置、区域事务与摘要器分别保留在独立模块中,服务自身注册自动监听器,而 `summarize()` 仍是唯一的子类 hook。单例计量器一致用于压力、保留、被遮蔽内容、来源以及非缩小摘要拒绝的定价。 自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 -压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、空的摘要提供方/模型、摘要最大输出 `8192`、一次额外压缩尝试,以及启用自动触发。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。`summarizationProvider` 与 `summarizationModel` 必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 +压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 -pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日志值,最近记录的请求头给出提供方、模型、工具及其他调用配置。没有完整提供方/模型组合的纯路由 agent(智能体)会跳过该临时检查,因为 `agent/request` 仍可稍后路由;任意已路由目标都可使用这个单例估算器。 +自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 ## 测试 -单元覆盖固定服务配置、固定估算、信封失效、提供方/模型切换时替换最新锚点、有无 usage 的路径、种子追加/替换回放、有符号增量、来源模式、畸形边界、统一快照的分离性与深度不可变性、表层总量相等性、监听器顺序、重载、压缩默认值、路由回退、自动决策单次调用、保留、收敛与日志修订回滚。真实 Loader/Include YAML fixture 按依赖顺序加载精确的零配置 token-meter 与 compact-basic 包名称。 +单元测试覆盖固定估算、信封失效与锚点替换、回放边界、不可变快照、已路由压力、收敛、溢出 generation 证明与回滚。真实 Loader/Include fixture 验证零配置 token-meter 与 compact-basic 按依赖顺序加载的路径。 ## 考虑过的替代方案 @@ -57,4 +57,4 @@ pre-step 触发器计量临时请求信封:当前提示词与前缀覆盖日 - 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 - 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 -- pre-step 压缩集成可能跳过纯路由的首次检查,也可能错过请求中间件稍后应用的工具或路由变化。 +- post-step 压力检查读取精确记录的路由、工具与前缀边界;对于在成功 usage 锚点出现前就被拒绝的请求,提供方溢出分类仍是由适配器维护的兜底路径。 diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md index 77fa2b4669..64961bee89 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md @@ -8,11 +8,11 @@ An ACP editor can keep several conversations alive over one agent subprocess. A ## Decision -The ACP bridge stores live sessions in `Map` and keeps a `WeakMap` reverse index for agent-scoped callbacks. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. +The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. Every `session/event` and `agent/status` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt records a log watermark, captures its own `turn/start`, and settles only on the matching `turn/end`; a late end from a cancelled prior turn cannot resolve a newer prompt. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path. -Permission ownership uses the same reverse index. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. +Permission ownership uses the same exact-agent check against the forward map. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. Background bash tasks carry an opaque owner token equal to the owning session id. `bash_output` and `bash_kill` compare the caller's token with the executor's task ownership before reading or killing; a predictable task id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 864428bc58..1d767d49fc 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,12 +17,12 @@ Two forces shape the design. First, compaction policy and reusable token measure Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter. 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation -The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs act on an agent-owned `Session` (`compactRegion(start, end, agent)`) and its output uses the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. @@ -30,27 +30,27 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. -### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam +### Automatic pressure runs after successful durable step work -Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change. +Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override. -The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`): +Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic forces one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases; the loop then opens a new numbered step and reconstructs its request from the durable log. No range, no replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` -assembly = ctx.systemPrompt.assemble() -await ctx.serial('agent/pre-step', agent, turn, step, system, prefix, signal) ⟵ compaction mutates the surface here -session('step/start') ⟵ the step opens AFTER the seam -messages = session.deriveMessages() ⟵ single derive, reflects the compaction -request = waterfall agent/request ⟵ pure request transform (hooks, model switch) -``` +assistant/message → tool/result/context/steering +await serial agent/post-step ⟵ pressure compaction inside the successful step +step/end -The loop derives messages once after `agent/pre-step`. Running before `step/start` keeps compaction records outside any half-open step, simplifying crash repair. The seam is awaited and serial so surface mutations cannot interleave; listeners return `void` and do not use Cordis bail values as vetoes. +provider overflow → step/end +await waterfall agent/request-error ⟵ forced compaction between attempts +retry → next numbered step/start ⟵ derives from the replacement surface +``` ### Retention is turn-agnostic; tool-pairing balance is the only structural guard -Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed. +Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. `compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization-model override, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If the compacted surface remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. +`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary @@ -90,12 +90,12 @@ The basic backend wraps the summary as established checkpoint context and tags i The `compact/start … compact/end` bracket is justified, in order of what now does the work: 1. **Crash-detectable orphan + provenance** (primary). Summarization is a slow model call persisted *after* `compact/start`. A crash mid-summarization leaves a `compact/start` with no matching `compact/end` — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from *silent corruption* into that detectable orphan. -2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across the awaited `pre-step`, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) +2. **Prevents concurrent compaction.** `compactRegion` refuses to start if the current turn holds an unmatched `compact/start`. (The loop is single-threaded across either awaited automatic seam, so this is also a re-entry tripwire — a thrown "already in progress" signals a real bug.) Two failure paths, both documented: -- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash can't wedge future compaction. Compaction simply re-attempts at the next `pre-step`. -- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set, leaving the surface untouched, and the model call proceeds with full history. +- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction. +- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and leaves the surface untouched. Post-step pressure warns and continues; overflow recovery delegates so the original provider error remains authoritative. `compact/end` keeps its `error?` field (mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling). There is no separate `compact/error` event. @@ -104,22 +104,22 @@ Two failure paths, both documented: ## Alternatives considered - **The full algorithm as concrete interface methods** — rejected because it recouples the contract to one retention strategy. Both core methods are abstract; reusable measurement is a separate LLM-family service and `summarize()` is basic's sole hook. -- **Compaction on the `agent/request` waterfall** — the earlier cut; rejected for the double-derive it forced and for handing the listener context it structurally cannot compact. The dedicated `agent/pre-step` seam makes the layering correct by construction. +- **Compaction on `agent/request` or provisional `agent/pre-step` inputs** — rejected because neither proves the final durable request and both couple generic lifecycle to compaction-specific envelope data. Post-step replay plus canonical overflow recovery covers both successful and rejected calls. - **A separate `compact/error` event** — rejected: `compact/end` keeps an `error?` field, mirroring `tool/result`'s self-contained error — one event tells success from failure without correlating a sibling. - **Teaching core turn-repair about `compact/*`** — rejected: the log-only orphan is inert, and a core module patched for every future `xxx/start … xxx/end` plugin pair is exactly the coupling the capability-seam architecture exists to avoid. ## Consequences - **Packages**: `packages/compact/compact` supplies the interface and `compact-basic` supplies the backend. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred. -- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature. +- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload. - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. +- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. ## Testing -- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn. -- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request. +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, forced below-threshold overflow, generation proof, caps, and original-error preservation. +- **Loop:** Tests pin post-step after durable tool results and before `step/end`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. - **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 83c7beb440..3f44940848 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -2,7 +2,7 @@ Status: implemented -> The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)). +> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)). ## Problem @@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: -- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); +- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. @@ -32,7 +32,6 @@ A new package group `packages/subagent/`: | `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` | | `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log | | `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process | -| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path | | `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | ### The primitive: async `start → SubagentRun` @@ -62,7 +61,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p ## Testing -The seam is tested through the real Cordis Loader/export path, which catches the export-shape failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. +Registry and tool tests replace only the nondeterministic child boundary with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Provider and consumer export shapes retain their Loader regression coverage for the failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. ## Consequences diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index d7c6631b94..8320dc4aa5 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -22,7 +22,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway `dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. -`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. +`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 4ac066e393..81e60a726f 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -49,45 +49,44 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After validation and an `approval/asked` append, `request()` resolves to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. The service borrows the readonly request, runs the answerer waterfall, races cancellation, and normalizes thrown or invalid answers to `unavailable`. It then appends the matching `approval/decided`, paired by `ApprovalRequestId`. +After validation and a successful `approval/asked` append, the service resolves the `approval/request` waterfall to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. It borrows the readonly request identity and signal, treats abort as `cancelled`, contains answerer failures and invalid returns as `unavailable`, discards late answers, and appends the paired `approval/decided` event. Pre-commit audit failures reject; post-append observer failures cannot undo an authoritative event. `allowed-once` authorizes only the asked action, and `request()` rejects outside an open turn so the audit pair remains inside the durable commit boundary. -Both audit events must be inside an open turn; acceptance or a pre-commit append failure rejects the request. Post-commit observers are contained by the session. `allowed-once` grants only the requested action, and the service retains no grant state. +Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates. -Answerers are `approval/request` waterfall listeners. A listener returns an outcome for an agent it owns and calls `next()` otherwise. With no answerer, the default is `unavailable`; unloading a UI therefore fails closed without leaving a channel. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and uses `prepend` only for decide-or-delegate gates. - -`ApprovalRequest` carries the agent, tool name, optional `callId`, reason, and signal. The agent routes both the prompt and audit events. The request uses `dsh-llm`'s `CallId` without importing `dsh-tools`, avoiding a package cycle. Tool arguments are omitted because UI answerers attach to the already-rendered call. +`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`. #### Ask routing in dsh-tools -`ToolRegistry.execute()` sends `ask` through the approval seam before the deny path. Only `allowed-once` proceeds; rejection, cancellation, and an unavailable channel produce distinct model-visible reasons. The registry looks up the optional service per call, so an absent or unloaded service fails closed without gating the registry fiber. Agent-less execution also fails closed because it cannot be routed or audited. +`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner. #### The per-session policy tier -The seam owns the session policy `'ask' | 'never'`, following the switching contract in the [sandbox RFC](2026-07-06-sandbox.md). The effective session or config policy is applied before answerers: `'never'` rejects inside `request()`, while `'ask'` dispatches and falls through to `unavailable` when unanswered. The prompt states only deterministic `'never'`; the narrator reports switches, and every request still receives its audit pair. +The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox RFC](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair. #### The ACP answerer -The ACP bridge finds the owning session, sends `session/request_permission` for the `callId`, and maps one-shot allow, reject, and cancel responses to the seam vocabulary. Unknown selections never grant. Foreign agents and requests without a `callId` delegate via `next()`; RPC failure becomes `unavailable`. The bridge answers requests but does not decide which calls require approval. +The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. -The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). +The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` and `approval/decided` are durable log-only events. The model sees only the asker's logged `tool/result`. Every accepted request appends one matching decision, including cancellation and contained answerer failures. +`approval/asked` and `approval/decided` are durable log-only events; the model sees only the ordinary tool result derived from the outcome. Successful completion commits one `decided` per `asked`, including cancellation and contained answerer failure. Idle requests append neither event; a pre-commit failure rejects, while failure of the second append can leave an already-committed `asked` unmatched. #### Entities and dependencies -`dsh-user-approval` owns the fixed dispatch-and-audit mechanism; `dsh-tools` asks and `dsh-acp` answers. Replaceable answerers remain listeners in their channel-owning plugins, so a three-package capability split would add an empty implementation layer. Sandbox executors remain transport-only, and static capability grants remain separate from interactive approval. +`dsh-user-approval` depends on Cordis plus the session, agent, and branded-call contracts; `dsh-tools` and `dsh-acp` consume it. The sandbox executor stays independent because `dsh-tool-bash` owns escalation requests. The fixed dispatch-and-audit service remains one package; replaceable answerers live with their channel owners. Static capability grants and `subagent-acp` child-side permission answers remain separate concerns. ### Testing -- **Unit/integration:** cover first-wins delegation, fail-closed defaults, malformed and throwing answerers, cancellation races and late-answer discard, audit pairing despite observer failures, unbypassable `'never'`, distinct tool-denial reasons, and ACP per-session routing/outcome mapping. -- **Snapshot:** script permission answers through both sandbox escalation branches and pin the `'never'` prompt plus policy-switch notice. Hook-produced asks without a composed answerer remain covered as fail-closed denial. +Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge. + +Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed. ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-produced ask with a composed answerer** — escalation records the human-prompt wire, while the current hook fixture pins the no-service denial; their combined producer/answerer path remains unit-covered. +- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. - **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered @@ -101,16 +100,18 @@ The answerer routes through the bridge's reverse-map ownership seam described by ## Consequences -- Only `allowed-once` dispatches an asked-about action; absent, rejected, cancelled, or failed answer paths deny. -- Session ownership routes prompts, policy, and audit events without crossing editor sessions. -- Accepted requests append one durable audit pair; the model sees only the resulting tool result. -- A deployment without the service emits no approval prompt or audit events and denies every `ask` at the tool boundary. +The implemented contract is pinned by the suites in Testing: + +- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting. +- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed. +- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject. +- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events. Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. +- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. ## FAQ @@ -118,10 +119,10 @@ Costs and accepted limits: - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. @@ -132,5 +133,5 @@ In-repo precedents this design copies or contrasts with: - The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses. - `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges RFC](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer. - [The interception-seams RFC](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services. -- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the `WeakMap` ownership seam the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. +- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the forward session map that the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. - The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it. diff --git a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md index 05f254a21e..0c642e7c68 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -16,26 +16,26 @@ Three properties carry the design: - **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire. - **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()` or tool/prompt-submit `additionalContexts` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter. -- **Composed before the pressure gate.** Composition precedes the instance's first `agent/pre-step`, and the seam hands the composed value through: `agent/pre-step` carries a `sessionPrefix` parameter and `CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. +- **Exact in the durable request envelope.** Composition precedes the instance's first `agent/pre-step` and request boundary. The first routed request logs the current prefix on its header, so post-step token pressure reads the exact prefix together with the actual prompt, tools, and routed model; no compaction-only parameter is carried through the generic pre-step seam. A composition interrupted by cancel/dispose is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal. Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface. ## Testing -**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition and no changed headers), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session header tests cover canonical prefix snapshots and latest-snapshot folding; dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on. +[Interception tests](../../../../packages/core/agent-loop/tests/interception.spec.ts) pin compose-once reuse without changed headers, prepend order, empty-prefix omission, immutability, composition before pre-step, and the prefix on the routed header; [cancellation tests](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pin discard and recomposition. Session, invariant, token-meter, and compaction tests cover header round trips, request reconstruction, and durable prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the [pinned-header scenario](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md) owns content and the default example remains prefix-free. The provider-independent seam needs no dedicated e2e; the with-key [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) covers its cache economics. ## Alternatives considered - **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites drift that must be logged as a full changed header, and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation. - **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with a full changed header when it changes) while the opener wants instance-frozen semantics. - **A durable history opener** (`inject()` at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes. -- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a full changed header, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. -- **Compose lazily at the first request and let compaction read the folded header** (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step. -- **A dedicated session event carrying the prefix** — rejected: request headers are the request's non-history record by design; a second event would be a second home for the same fact. +- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a changed header, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably. +- **Carry prompt/prefix through `agent/pre-step` for provisional pressure** — rejected because it couples a generic lifecycle seam to one consumer and still misses later request routing and tools; post-step replay reads every request-envelope field from its durable routed header. +- **A dedicated session event carrying the prefix** — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total. ## Consequences -- `agent/pre-step` and `CompactService.compactIfNeeded` carry a `sessionPrefix` parameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance). +- `agent/pre-step` stays a generic `(agent, turn, step, signal)` checkpoint. Compaction receives no prefix parameter; `ctx.tokenMeter` folds the prefix from the canonical routed header at post-step. - A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable `context/message`. - The dropped `after` slot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid. - An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation. diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 4996fc2935..74f4216565 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -12,11 +12,10 @@ The harness already has every seam the pi extension uses, and better ones: [the The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecutive calls to the same tool with identical canonical arguments and injects advisory reminders at configured thresholds. It never delays, blocks, or rewrites a call; the model decides whether to retry differently or finish. -The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. +The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers two listeners and holds state in a `WeakMap` keyed by the live `Agent` object — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish; weak object keys also make a disposal-only cleanup listener unnecessary. - **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, prepends a reminder to the downstream decision's `additionalContexts` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. - **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop. -- **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime. ### Detection semantics @@ -25,7 +24,7 @@ The chain key is `(tool name, canonical arguments)`; a call identical to the pre Two deliberate rules, both documented in [the package README](../../../../packages/guard/repeat-tool-guard/README.md) because they are behavior a reader would otherwise guess at: - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no `AgentId` to key on. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no live agent object to key on. ### Reminder delivery diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 4d592af42b..94d11ee931 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -18,11 +18,11 @@ The vm isolates accidental global pollution, and the context façade hides frame | Tool | Contract | |---|---| -| `cordis_inspect` | Read-only report over the live runtime, one Markdown section per `what` value (omit `what` for all sections). Never mutates. | +| `cordis_inspect` | Read-only report over the live runtime, one Markdown section per `what` value (omit `what` for all sections). An exact `name` with `what: "api"` or `what: "events"` narrows to one source-documented target. Never mutates. | | `cordis_mount` | Evaluates `code` (the body of an async JavaScript function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted as a child of the `cordis-dynamic` group fiber and tracked under a fresh id (`dyn-1`, `dyn-2`, …). | | `cordis_unmount` | Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. | -`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../tool-catalog.md) is their exhaustive rendering. +`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). Broad `api` and `events` reports omit full JSDoc to stay compact; an exact `name` returns one service or event with its original method/declaration JSDoc. A name is invalid with other sections, unknown targets fail, and an API target must be live. The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../tool-catalog.md) is their exhaustive rendering. ### Sandbox semantics @@ -44,9 +44,9 @@ Mounts relate to each other through ordinary cordis service semantics, with thei ### The generated API catalog -`cordis_inspect` serves API and event data from a generated catalog rather than a duplicated table. The generator reuses the Cordis catalog AST scan and emits service summaries, signatures, event modes, referenced type declarations, and the inherited context surface. Ambiguous type names are omitted and oversized declarations are marked as truncated. +`cordis_inspect` serves API and event data from a generated catalog rather than a duplicated table. The generator reuses the Cordis catalog AST scan and emits service summaries, signatures, original service-method and event JSDoc, event modes, referenced type declarations, and the inherited context surface. Ambiguous type names are omitted and oversized declarations are marked as truncated. -Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc edit that changes a public signature cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: live catalogued services render summary + signatures, live services without a catalog entry (mount-provided ones) render name + owning fiber, catalogued services with no live provider are listed tersely, and the referenced type shapes follow. +Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc or public-signature edit cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: broad reports render live catalogued services as summary + signatures, live services without a catalog entry (mount-provided ones) as name + owning fiber, catalogued services with no live provider tersely, and then the referenced type shapes. Exact-name reports render one live service or event with the original JSDoc immediately before each signature; keeping that detail opt-in avoids charging its token cost on exploratory listings. ### Configuration, rendering, and observability diff --git a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index bc8a452c91..35f65c38f2 100644 --- a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -153,7 +153,7 @@ If the complete logical result fits under the inline cap, no formatted spill art - The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display. - The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model. - Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`. -- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. ## Risks diff --git a/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md new file mode 100644 index 0000000000..90fbc1cfb4 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -0,0 +1,101 @@ +# RFC: Parallel tool-call execution by per-call safety + +Status: implemented + +## Problem + +An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together. + +Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. + +The session log remains authoritative: every started call has an audit event, every started call receives a result, and model history observes results in the original call order regardless of completion order. + +## Decision + +Each tool may provide an optional `isConcurrencySafe(args)` classifier. It is synchronous and pure: it examines only the current call's parsed arguments and performs no I/O or mutation. Only an explicit `true` opts in; a missing classifier, invalid arguments, a thrown classifier, or any other return value makes the call exclusive. The canonical type contract lives in the [tool data structures](../../../core-data-structures/tools.md). + +The classifier is deliberately unary. Returning `true` is the tool's promise that this call may overlap with any sibling call that also returns `true`; the scheduler does not compare calls or prove that their resource accesses are compatible. + +The unary classifier remains input-sensitive. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive. + +`defineTool()` validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. `ctx.tools.executionMode(exec)` resolves the live tool definition and returns the tagged `parallel` or `exclusive` mode; unknown tools fail closed to exclusive. + +A tagged mode, rather than a public boolean scheduler API, keeps resource-aware variants representable without changing the classifier contract. + +## Scheduling and ordering + +The loop waits for the complete assistant message, parses every call once, creates a distinct `ToolExecution` for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier. + +For example: + +```text +[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)] + +→ [read(A), read(B)] +→ [write(A)] +→ [read(C)] +``` + +`read(A)` and `read(B)` may overlap. `write(A)` starts after both finish, and `read(C)` starts after the write finishes. + +Every group uses a rolling pool bounded by `maxParallelToolCalls`: the loop starts calls in model order up to the cap and starts another whenever one settles. An exclusive group is a pool of one. A cap of `1` preserves serial execution. + +Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-execute` run in model order because middleware may maintain ordering-sensitive state. `tools/execute` wrappers run around concurrent dispatches and therefore must be reentrant across distinct executions. + +Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered. + +An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. + +Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. + +## Safety contract + +A tool that returns `true` promises that its body is safe to run at the same time as other parallel calls. It must not directly mutate the parent session or other parent-owned state; it returns its outputs to the loop, which commits them in model order. + +Any shared state touched during execution must be concurrency-safe. This includes tool wrappers and providers: they may serialize internally or enforce their own capacity, but they must support concurrent dispatch without corrupting state. + +## Configuration and declarations + +`maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../config-catalog.md). + +The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive. + +Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. + +## Verification + +Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, and abort draining. First-party tests pin each parallel declaration. + +Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. + +## Alternatives considered + +**Keep serial execution.** This avoids new ordering and abort cases but retains unnecessary latency for independent sibling calls. + +**Use one tool-level boolean.** A fixed `supportsParallelToolCalls` flag is smaller but cannot distinguish a tool's read-only and mutating operations. The argument-sensitive classifier preserves that distinction. + +**Use stateful classification.** Giving the classifier a live agent, registry, or I/O access makes the decision depend on when it runs and creates a gap between classification and dispatch. Mutable authorization and stale-state checks remain execution-time responsibilities. + +**Use sibling-aware or resource-aware classification.** The scheduler could compare calls pairwise or let each call declare resource read/write claims. This can parallelize non-conflicting writes, but it requires shared resource identity and conflict semantics across unrelated tools. The unary contract instead gives up that concurrency and fails closed when safety is relational. + +**Parallelize the complete tool pipeline.** This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps. + +**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy seam. + +**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete. + +**Use fixed-size windows.** Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay. + +**Expose concurrency metadata to the model.** The model can already emit sibling calls. Host scheduling metadata would enlarge requests without improving tool choice. + +## Consequences + +The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races. + +Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation. + +Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress. + +Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step. + +Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool. diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml new file mode 100644 index 0000000000..8618fd884f --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-dedicated-full-screen-tui-front-door.md: c834594b3af1e1f348aec2cf67324d5123a1ed2d +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 8cb8eb6d2812e6ecf9d98d20c64da24c2943363c diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md new file mode 100644 index 0000000000..c834594b3a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -0,0 +1,48 @@ +# RFC: Dedicated full-screen TUI front door + +Status: implemented + +English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) + +## Problem + +The line-oriented `@deepseek-ai/dsh-stdio` front door works in pipes and ordinary terminals, but a full-screen coding interface must own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin couples the pipe-safe path to a TTY-only lifecycle and makes it unclear which terminal behavior a composition selects. + +The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. + +## Decision + +DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. + +The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `repl-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the repl-agent backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. + +The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. + +### Session projection and interaction + +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle. + +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services. + +### Terminal ownership + +Before model output, session data, tool presentation, questions, configuration, or diagnostics reach pi-tui or the terminal title, `displayText()` renders C0 and C1 controls other than line feeds as visible hexadecimal escapes. Only the TUI and pi-tui create ANSI control sequences. + +The built-in palette uses standard 16-color ANSI foregrounds and SGR attributes, keeps body text and backgrounds at terminal defaults, and uses reverse video for selection. Host terminals therefore remap the interface for light and dark themes without a TUI-specific theme setting; `color: false` removes styling. + +## Verification + +The implemented [TUI terminal-state snapshot RFC](../testing/2026-07-18-tui-terminal-state-snapshots.md) owns the four-layer verification contract: direct behavior tests, transient semantic terminal snapshots, recorded JSONL journeys through production tools, and Loader/PTY smoke tests. The package README owns configuration, commands, model-visible effects, and current limitations. + +## Alternatives considered + +- **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. +- **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. +- **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. + +## Consequences + +- Interactive terminal work gains a stateful Markdown, card, plan, and question interface without changing the line-oriented protocol used by pipes and automation. +- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments select `@deepseek-ai/dsh-stdio` at composition time. +- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. +- Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md new file mode 100644 index 0000000000..8cb8eb6d28 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -0,0 +1,48 @@ +# RFC: 独立的全屏 TUI 入口 + +Status: implemented + +[English](2026-07-17-dedicated-full-screen-tui-front-door.md) | 中文 + +## 问题 + +逐行输出的 `@deepseek-ai/dsh-stdio` 入口适用于管道和普通终端,但全屏编码界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使管道安全路径依赖仅适用于 TTY 的生命周期,也使组合无法明确表达所选终端行为。 + +交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 + +## 决策 + +DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 + +应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`repl-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 repl-agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 + +所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 + +### 会话投影与交互 + +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 + +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题;agent 行为和答案日志仍由既有服务负责。 + +### 终端所有权 + +在模型输出、会话数据、工具呈现、问题、配置或诊断信息进入 pi-tui 或终端标题前,`displayText()` 会把换行之外的 C0 和 C1 控制字符显示为十六进制转义文本。只有 TUI 和 pi-tui 可以生成 ANSI 控制序列。 + +内置配色仅使用标准 16 色 ANSI 前景色和 SGR 属性,正文文字和背景沿用终端默认值,选中项使用反显。因此,宿主终端可以直接按浅色或深色主题重映射界面,无需 TUI 专用主题设置;`color: false` 会移除样式。 + +## 验证 + +已实现的 [TUI 终端状态快照 RFC](../testing/2026-07-18-tui-terminal-state-snapshots.md) 规定四层验证契约:直接行为测试、瞬态语义终端快照、通过生产工具执行的已录制 JSONL 流程,以及 Loader/PTY 冒烟测试。包(package)README 负责记录配置、命令、模型可见效果和当前限制。 + +## 曾考虑的替代方案 + +- **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 +- **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 +- **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 + +## 后果 + +- 交互式终端获得带状态的 Markdown、卡片、计划和提问界面,同时不会改变管道与自动化使用的逐行协议。 +- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署在组合时选择 `@deepseek-ai/dsh-stdio`。 +- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 +- 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index aaac6fbd3c..47dc370d22 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -10,7 +10,7 @@ So the work had two intertwined questions: **what belongs in such a catalog** (t ## Decision -A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type definition byte-identical to its source. +A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type declaration and its JSDoc synchronized with source. ### What counts as "core" — the spine-vs-seam line @@ -27,10 +27,10 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei ### The `ts type-equiv` mechanism — literal AND drift-proof -The durability requirement was specific: the doc should show the **literal** current type definition (so a reader sees the real shape, not a paraphrase) **and** be mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability*, not *byte-equality* — a renamed field with the same type would pass. So: +The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So: -- Type definitions are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. -- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts a **verbatim source match** against the declared symbol — chosen over a compiled `_Check` assertion precisely because byte-equality, not assignability, is the property we want. +- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. +- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. @@ -41,7 +41,7 @@ The durability requirement was specific: the doc should show the **literal** cur ## Alternatives considered - **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won. -- **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. +- **A compiled `_Check` assignability assertion** instead of the source match — rejected because assignability does not preserve names or JSDoc: a renamed field with the same type or a changed contract comment would pass. - **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. ## Verification lesson @@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de ## Consequences -- The vocabulary now has a single home that **cannot silently drift**: a field rename in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. +- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here. - The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering. - The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment. - Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist. diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index eafce4feae..879f8de701 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -12,7 +12,7 @@ This is the wiring-axis complement to the [core-data-structures catalog](../../. Generate the catalog from source instead of hand-maintaining a table and verifying a subset. -`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes; services include public signatures. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. +`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes and their original member JSDoc; services include public signatures with each method's original JSDoc. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). @@ -20,8 +20,8 @@ Specific choices: - **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel|serial` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit/parallel/serial distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`, as does the ordered `agent/pre-step` checkpoint), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). - **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. -- **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. -- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. +- **Cross-links to the data-structure catalog.** Every repository-owned type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to its primary core-data-structures page through a curated map. The AST walk is fail-closed: each parameter, generic constraint/default, and return-type reference must be mapped, be the signature's own type parameter, be a named TypeScript/Cordis foundation type, or carry a named exception with its non-catalog documentation owner. Violations aggregate with source pointers and name the appropriate owning lists. The map does NOT reuse `type-equiv.manifest.json`, which documents `…Map` symbols while signatures reference derived union names and lists some symbols on multiple pages. +- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string and place the original event or public-method JSDoc immediately before its declaration. `doc-typecheck` recognizes and skips the bare fragments, excluding them from the opt-out ratio — the same treatment `type-equiv` blocks get. This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged. @@ -29,11 +29,11 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11 - **Verify-don't-generate, as the retired taxonomy check did** — reversed *for this surface only*: the data here is mechanically complete, so generation is strictly stronger (full signatures, cannot drift, catches undocumented events) than a name-set check of a hand-maintained table. - **Walking the vendor AST for the inherited tier** — rejected for the curated table: the cordis-core `Context` mixes true ctx members with non-service fields, and the pinned vendor surface changes only on a deliberate sync. -- **Reusing `type-equiv.manifest.json` as the signature cross-link map** — rejected for a small hand-curated const: the manifest documents the `…Map` symbols while signatures reference the derived union names, and it lists a few symbols on two pages. +- **Reusing `type-equiv.manifest.json` as the signature cross-link map** — rejected for a complete curated const plus fail-closed coverage: the manifest documents `…Map` symbols while signatures reference derived union names, and it lists some symbols on multiple pages. The explicit map makes each rendered destination and each non-catalog exception a reviewable decision. ## Consequences -- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, or a tag that contradicts its signature, fails the generator outright. -- Event prose now has a single home — the JSDoc at the declaration. Thin JSDoc yields a thin catalog entry, which pressures authors to document at the source (the generator is a forcing function for the AGENTS.md "every export has a semantic JSDoc" rule). +- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright. +- Event and service-method contracts have a single home — the JSDoc at the declaration. The catalog repeats that original JSDoc inside its generated signature block and uses its description portion as entry prose, so thin source documentation yields a thin catalog entry. - The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator. - `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead. diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md index a17973ea2e..4e3148ead6 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -34,7 +34,7 @@ The first index links ten relationship surfaces. Package topology and tool-packa | [tool schema catalog and package map](../../../tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | | [capability seams and core services](../../../capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | | [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | -| [coding-agent app composition](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [repl-agent app composition](../../../../examples/repl-agent/composition.md) | hybrid generated | `examples/repl-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [acp-agent app composition](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [event producer/consumer matrix](../../../event-producer-consumer.md) | hybrid generated | Cordis event declarations, AST-scanned `ctx.on/emit/parallel/serial/waterfall` sites, and explicit dynamic dispatch overrides | | [agent turn and step lifecycle](../../../agent-lifecycle.md) | curated | architecture.md loop lifecycle, Cordis catalog links, and session event semantics | diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 42ab306bb0..1c7e515c4f 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -20,14 +20,14 @@ The contract: - **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). - **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. -The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. +The generator keeps two views of the same source comment: `parseJsDoc` ends entry prose at the first block tag, while the `ts cordis-catalog` signature block includes the original JSDoc with `@param`, `@returns`, and `@mode` intact. Readers therefore see the complete source contract without block-tag text leaking into the surrounding prose. Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. ## Alternatives considered - **An ESLint rule** — cannot see the scope's machine definition (which `interface Events` members and which `ctx.` classes are the cordis surface); the catalog generator computes exactly that mapping on every run, so the gate lives there. -- **Rendering the tags into the catalog** — restructuring the services section into per-method entries was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. +- **Expanding every method into a separate prose section** — rejected: the catalog stays skimmable by keeping one service section and one signature block, while the JSDoc attached to each declaration preserves the full method contract in place. - **An escape-hatch tag** — none exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. ## Consequences @@ -36,4 +36,4 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr - The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. - The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. - `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. -- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. +- Each generated event or method fragment carries its original JSDoc, while the prose summary remains tag-free. Source edits therefore refresh both the readable index and the exact contract shown beside the signature. diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index 0de6255322..70a6d60c1a 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -4,19 +4,19 @@ Status: implemented ## Problem -`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event and payload; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output. +`SessionEventMap` is the on-disk vocabulary, but its declarations are split across the owning session package and declaration merges. The generated persistence catalog is the single reference for every event, its complete payload declaration and source JSDoc, and the shared `SessionEvent` envelope; hand-maintained tables drift and are removed. These records are not Cordis events—observers receive them through the single `session/event` bus event—so the Cordis catalog cannot cover them. The generator discovers all declarations and the doc-sync freshness gate rejects omissions or stale output. ## Decision Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools). -`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders source JSDoc, payload type, derived surface badge, reference links, and source location. The doc-sync freshness check rejects a vocabulary change whose catalog was not regenerated. +`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders each member from its leading JSDoc through the complete payload type, retaining nested property comments and removing only its containing indentation, and also pastes the owning `SessionEventType`, `SurfaceEventType`, `SurfaceOp`, and `SessionEvent` declarations that compose the persisted envelope. Derived surface badges, reference links, and source locations remain outside the declaration blocks. The doc-sync freshness check rejects a vocabulary or envelope change whose catalog was not regenerated. Specific choices: -- **JSDoc completeness, enforced.** Every member must carry description prose — the JSDoc becomes the catalog entry, the same forcing function the cordis catalog applies to bus events. An `@mode` tag on a member is a hard error: dispatch modes belong to cordis bus events, and a log event has none — the tag would misread as "this fires on the bus with mode X". Violations aggregate into one error listing every offender. +- **JSDoc completeness, enforced.** Every member and rendered envelope type must carry description prose, and the full source JSDoc stays attached to its declaration in the catalog. An `@mode` tag is a hard error: dispatch modes belong to cordis bus events, and persisted records have none. Violations aggregate into one error listing every offender. - **The surface badge is derived, not hand-listed.** `SurfaceEventType` — the subset that produces LLM messages and may carry `surfaceOp` — is parsed from its union declaration in the owning package; a union member naming no declared event is a hard error (a stale union member would otherwise silently badge nothing). Everything else renders **log-only**. -- **A dedicated fence.** Payload blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (a bare payload fragment is not standalone-compilable). +- **A dedicated fence.** Declaration blocks use a ` ```ts persistence-catalog ` info string that `doc-typecheck` recognizes and skips, excluded from the opt-out ratio — the same treatment as `ts cordis-catalog` (the declarations reference types from their owning modules and are not standalone-compilable). - **Repo scope.** The catalog enumerates the packages in this repo, matching the siblings' packages-only scope; a downstream plugin can merge further event types, which are outside the catalog by construction. The walk defends its own assumptions with hard errors: the owning top-level `interface SessionEventMap` must be the single exported declaration in `@deepseek-ai/dsh-session` (an unrelated, local, or duplicate same-named interface cannot be catalogued as the on-disk vocabulary), no declaration may carry `extends` (inherited keys would join `keyof SessionEventMap` without a catalog row), every member must be a property signature with an explicit payload type (a method-form member would join `keyof` yet slip past a silent walk), and a duplicate member across declarations fails. This supersedes the hand-copies: the session.md `hook/*` table, the compact README's event table, the hook-protocol README's payload bullets, and the session README's name-list now link the catalog instead of restating payloads (the surrounding semantics prose stays where it was). The two stray `@mode emit` tags on the hook-protocol merge members are removed — the new gate rejects them as the category error they were. @@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ ## Consequences -- The catalog cannot drift: a vocabulary change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. -- Event prose has a single home, the JSDoc at the declaration; thin JSDoc yields a thin catalog entry, pressuring authors to document at the source. +- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. +- Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them. - The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler. - The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change. diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md index 0f9b0b02a0..d0844f4f3b 100644 --- a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -4,28 +4,28 @@ Status: implemented ## Problem -A package README can explain APIs and runtime mechanics without answering the question that dominates an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, and how long those tokens remain. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. +A package README can explain APIs and runtime mechanics without answering the questions that dominate an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, how long those tokens remain, and whether later requests preserve a reusable KV-cache prefix. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. ## Decision Every workspace package README with a model-facing or model-adjacent contract ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. An audited model-agnostic generic package omits the section through `NO_MODEL_EXPERIENCE_SECTION`. -Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each names what the relevant model receives and when, then classifies the token effect. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a nested H4 plus `markdown` fence, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. +Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each surface contains three ordered H4 fields—`What the model sees`, `Token effect`, and `KV Cache effect`—and each field starts with one prose paragraph. The cache field distinguishes append-only growth, a stable repeated prefix, replacement of earlier tokens, and an independent model request; it names every package-owned configuration, scope, lifecycle, compaction, or routing change that can alter the request before newly appended content. “Does not invalidate” means the package preserves an already-reusable prefix, not that a provider promises a cache hit or retention period. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a titled H5 plus `markdown` fence under the field that introduces them, normally `What the model sees`, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. -A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited one-sentence form: `None, as ` or `Indirectly, through `. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sentences locate the contribution without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. +A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited short form: one sentence beginning `None, as ` or `Indirectly, through ` followed by a `KV Cache effect` H4 and one prose paragraph. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sections locate the contribution and disclaim direct cache invalidation without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. -`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, required fields, concrete literal evidence, nested verbatim blocks, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. +`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, exact field heading depth and order, non-empty field paragraphs, H5 ownership of verbatim blocks, concrete literal evidence, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. ## Alternatives considered - **Document only packages that register prompts or tools** — rejected because backends, policy plugins, adapters, persistence, scoping, and compaction change the content or lifetime of tokens without owning a model-facing schema. - **Generate one central context-cost catalog from source** — rejected because an AST can find registrations but cannot infer semantic conditions such as history retention, output truncation, parent-versus-child visibility, or an auxiliary model boundary. The package README is the implementation-local contract; a central copy would add another drift surface. - **Require numeric token counts** — rejected because exact counts depend on the selected model tokenizer, adapter serialization, configuration, and runtime data. The stable contract is the growth shape: fixed per request, conditional per call, retained, replaced, capped, or zero-direct. -- **Use a three-column table** — rejected because exact source text and conditional result shapes make cells dense and difficult to scan. Repeated subsections give each context surface readable vertical space while preserving the same fields. +- **Use a table** — rejected because exact source text and conditional result shapes make cells dense and difficult to scan. Repeated subsections give each context surface readable vertical space while preserving the same fields. - **Allow every zero-impact package to omit the section** — rejected because unconstrained absence is ambiguous between an audited zero and forgotten documentation. Omission is reserved for model-agnostic generic packages named with a reason in the verifier; model-adjacent zero-impact packages keep one explicit sentence. -- **Require the full structured form for audited zero or simple indirect packages** — rejected because it repeats labels around one fact. A gated sentence preserves explicit coverage without the ceremony. +- **Require the full structured form for audited zero or simple indirect packages** — rejected because it repeats labels around one fact. A gated sentence plus cache field preserves explicit coverage without the ceremony. - **Convention without a gate** — rejected because a repo-wide contract must also cover every future package; review memory cannot reliably detect an omitted README section. ## Consequences -A reviewer can start at any model-facing or model-adjacent package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, and agent-scoped changes have an explicit documentation checkpoint. Package authors maintain one or more compact context-surface blocks or one classified sentence whenever model-visible behavior changes; audited generic packages carry no irrelevant model boilerplate. The structured fields do not promise provider-exact token counts; measurements remain model- and workload-specific, while the documented growth and visibility contract stays stable. +A reviewer can start at any model-facing or model-adjacent package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, while cache-sensitive work can identify append-only paths and the earliest package-owned prefix mutation. Agent-scoped changes have an explicit documentation checkpoint. Package authors maintain one or more compact context-surface blocks or one classified short form whenever model-visible behavior changes; audited generic packages carry no irrelevant model boilerplate. The structured fields do not promise provider-exact token counts or cache hits; measurements remain model-, provider-, and workload-specific, while the documented growth, visibility, and prefix-stability contract stays stable. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index e2a1165846..f4b49281ea 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -2,9 +2,18 @@ Status: implemented + + ## Problem -The loop exposed durable turn and step boundaries through both the replayable `SessionEvent` log and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. ACP and persistence already used the log; the stdio UI was the only remaining mirror consumer and already rendered tool calls and results from `session/event`. +The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. @@ -12,19 +21,25 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that needs an agent label maintains a session-to-agent map from `agent/created` and `agent/disposed`, because the durable `turn/start` carries the turn number but not the agent id. +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle at a boundary retains the live target object from `agent/created`/`agent/disposed` and compares its session directly; `dsh-ui-stdio` uses this to label the app-owned agent's `[main turn N]` header while other sessions render their durable id. The canonical record remains the event-sourced session log. -The step mirrors had no consumers and were removed first by the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md). That decision retained the turn mirrors for the stdio UI; this RFC removes them after migrating that test REPL to `session/event` and the id map. +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it reads `session/event` and retains only its live target object. ## Scope: what is and isn't removed -This decision covers only durable turn and step boundaries. `agent/steering` mirrored a control record and `agent/stream-chunk` mirrored the token stream, so each was handled separately: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors; queued input may be cancelled before any durable event exists. +Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. + +RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: + +- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). +- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). +- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. ## Alternatives considered -- **Remove `agent/steering` in the same change** — rejected because it was a control-record mirror rather than a boundary mirror. -- **Keep turn mirrors for the stdio UI** — rejected because the UI can render `session/event` and recover the agent label from its id map. +- **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). +- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` plus its live target object instead. ## Consequences -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It subscribes to `session/event` and, if it needs the live object, resolves the shared id through `ctx.agents` or retains the object it already owns. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md new file mode 100644 index 0000000000..9084befad5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -0,0 +1,38 @@ +# RFC: Unify the agent id and the session id + +Status: implemented + +## Problem + +A live agent/session pair needs one identity for registry routing, event sourcing, and persistence. Giving the factory independent `agentId` and `sessionId` inputs would permit pairings no production path can use, while forcing every consumer to choose or translate between two names for the same lifecycle. + +ACP uses the same value for both identities. Stdio and hooks also operate on the session event stream and need the corresponding live agent directly; no production path reattaches one live agent object to several sessions or drives one session through several agent ids. + +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) uses one `AgentCreationTransaction` for create and resume, and agent/session entries share the same final-entry collision rule. A second identity would not represent separate liveness, rollback, or quiescence; it would only add API and translation state around the same transaction. + +Session identity likewise has one home in `Session.header.id`; `Session.id` is a derived accessor rather than independent state that needs duplicate validation. + +## Decision + +An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. + +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. An ordinary fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide. A coupled app may pre-mint and pass an exact `sessionId`: first use creates it, while an AgentLoop remount with an already-present persistence service resumes materialized history under that same identity. `resumeSessionId` instead requires an existing persisted identity. The two exact-id inputs are mutually exclusive. Stdio uses the resume-or-create form so its config-created agent and UI share one opaque identity across loop reloads instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. + +`agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. + +## Alternatives considered + +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. + +## Verification + +- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC forwards only lifecycle events whose service-snapshotted `local` flag is true, obtains the delegating parent from the scoped event carrier, and keeps no child identity or lineage cache. +- The config-driven resume-or-create policy is explicit and covered across a durable restart. +- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Consequences + +This forecloses latent multi-session-actor and session-handoff designs and makes persisted client-chosen session identity the registry identity. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index a272dfe0b2..6ca85b3d16 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -42,4 +42,4 @@ Not touched: ## Consequences -A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event` and filters `assistant/chunk` (the `Agent` handle, if needed, is recovered from a session-id→agent map built from `agent/created`/`agent/disposed`, exactly as boundary consumers already do). No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. +A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event`, filters `assistant/chunk`, and looks up the corresponding live handle directly with `ctx.agents.get(session.id)` when needed. No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index d3775754b9..43f35ee770 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml new file mode 100644 index 0000000000..b86d9405e0 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-retire-subagent-mock-package.md: 47174d22eaf27c5a5509280793dd50d70c542d00 +2026-07-19-retire-subagent-mock-package.zh.md: bfad78e1912f9d5153196df3fea2494c88436a91 diff --git a/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md new file mode 100644 index 0000000000..47174d22ea --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md @@ -0,0 +1,34 @@ +# RFC: Retire the standalone subagent mock package + +Status: implemented + +English | [中文](2026-07-19-retire-subagent-mock-package.zh.md) + +## Problem + +`@deepseek-ai/dsh-subagent-mock` was a configurable test double packaged as a workspace plugin. Its only external consumers were the `tool-subagent` unit suite and the tool-catalog generator; no runtime package, example, snapshot configuration, or real provider loaded it. + +That narrow fixture carried a manifest, exports, peer and development dependencies, project references, package README obligations, Loader composition tests, module-graph membership, and documentation exceptions. The tool-catalog generator mounted it only to make production consumers register their schemas and never executed a child. + +## Decision + +The standalone package is deleted. Its scripted child behavior now lives in `packages/subagent/tool-subagent/tests/scripted-provider.ts`, where tests mount the real `SubagentService`, provider registry, tool implementation, and task runtime while replacing only the nondeterministic child boundary. + +The local fixture retains deterministic replies, structured results, stop reasons, cancellation before and after publication, conversation-inheritance descriptors, and effect-scoped disposal. Package-specific Schemastery and Loader-export tests disappear because the fixture is no longer a deployable plugin. + +The tool-catalog generator registers a minimal local `SubagentProvider` descriptor before mounting `ToolSubagent` or the workflow engine. The descriptor cannot start a child; it exists only to satisfy production load-time dependencies while harvesting schemas from the real consumers. + +Workspace project references, package dependencies, lockfile entries, graph metadata, support-package prose, config-catalog entries, and README gate exceptions no longer name the retired package. + +## Alternatives considered + +**Keep a reusable mock package for future tests.** Reuse never materialized outside one test file and one generator. A future second behavioral consumer can extract a shared fixture after its contract is known; pre-packaging it made test infrastructure look like a supported backend. + +**Generate subagent schemas without mounting production consumers.** Hand-constructing or importing schemas would weaken the catalog check that the real registry and tool composition expose the documented shape. A minimal provider descriptor preserves that check without carrying executable fake-backend behavior. + +## Consequences + +- The workspace has one fewer deployable package and no test-only node in the capability or module graphs. +- `tool-subagent` tests retain foreground, background-task, lifecycle, cancellation, reply, stop-reason, and structured-result coverage through production services. +- Tool-catalog output remains generated from production registrations and is byte-for-byte unchanged. +- Runtime and example packages gain no dependency on test fixtures. diff --git a/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md new file mode 100644 index 0000000000..bfad78e191 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md @@ -0,0 +1,34 @@ +# RFC: 撤销独立的 subagent mock 包 + +Status: implemented + +[English](2026-07-19-retire-subagent-mock-package.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-subagent-mock` 曾是一个以工作区插件形式发布的可配置测试替身。它仅有两个外部消费方:`tool-subagent` 单元测试和工具目录生成器;运行时包、示例、快照配置和真实提供方都不会加载它。 + +这个用途狭窄的 fixture(测试前置数据)需要维护 manifest(元数据清单)、导出、对等依赖(peer dependency)与开发依赖、项目引用、包(package)README 契约、Loader 组合测试、模块图成员关系以及文档例外。工具目录生成器挂载它,只是为了让生产消费方注册 schema,并不会执行子 agent。 + +## 决策 + +删除独立包。脚本化子 agent 行为现位于 `packages/subagent/tool-subagent/tests/scripted-provider.ts`;测试挂载真实的 `SubagentService`、提供方注册表、工具实现和任务运行时,只替换具有不确定性的子 agent 边界。 + +本地 fixture 保留确定性回复、结构化结果、停止原因、发布前后的取消、对话继承描述和作用域化的 dispose(资源释放)覆盖。由于 fixture 不再是可部署插件,删除包专用的 Schemastery 与 Loader 导出测试。 + +工具目录生成器在挂载 `ToolSubagent` 或工作流引擎之前,注册一个最小本地 `SubagentProvider` 描述。该描述无法启动子 agent;它只用于满足生产消费方的加载时依赖,同时从真实消费方提取 schema。 + +工作区项目引用、包依赖、锁文件条目、图元数据、支持包说明、配置目录条目和 README 门禁例外不再提及已撤销的包。 + +## 备选方案 + +**为未来测试保留可复用 mock 包。** 除一个测试文件和一个生成器外,复用需求始终没有出现。未来产生第二个行为消费方时,可以在共享契约明确后再提取 fixture;提前将其打包会使测试基础设施看起来像受支持的后端。 + +**不挂载生产消费方,直接生成 subagent schema。** 手工构造或直接导入 schema,会削弱目录门禁对真实注册表与工具组合是否公开文档结构的校验。最小提供方描述能保留该校验,而无需携带可执行的虚假后端行为。 + +## 影响 + +- 工作区减少一个可部署包,能力图与模块图也不再包含测试专用节点。 +- `tool-subagent` 测试继续通过生产服务覆盖前台、后台任务、生命周期、取消、回复、停止原因和结构化结果。 +- 工具目录输出仍根据生产注册生成,并保持字节级一致。 +- 运行时包与示例包都不会依赖测试 fixture。 diff --git a/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml new file mode 100644 index 0000000000..55a113d22b --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-use-one-session-surface-manager.md: 0c0fd70717ff4b49e8817e88592d0781d9ccbd09 +2026-07-19-use-one-session-surface-manager.zh.md: 6d11c9bb475b0ee5a568f96678d0251f1160dda3 diff --git a/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md new file mode 100644 index 0000000000..0c0fd70717 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md @@ -0,0 +1,37 @@ +# RFC: Use one surface manager per session + +Status: implemented + +English | [中文](2026-07-19-use-one-session-surface-manager.zh.md) + +## Problem + +`Session` maintained two `SurfaceManager` instances over the same append-only event log. One validated seed and append candidates, while a second lazy instance independently folded committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface had been read, every later event advanced duplicate node and replacement-generation state without creating a separate authority or failure boundary. + +## Decision + +Each `Session` owns one eagerly constructed `SurfaceManager`. Seed and append acceptance call `validateNext()` on that manager before committing an event, and `session.surface` returns the same object through this readonly contract: + +```ts +export interface SessionSurface { + readonly nodes: readonly number[] + readonly replaceGeneration: number +} +``` + +Candidate validation remains atomic. `validateNext()` may synchronize committed log entries, but it only plans the uncommitted candidate. The candidate enters manager state after `log.push()` and the next delta synchronization, so surface validation failures and pre-commit `internal/dispatch` vetoes leave no phantom node or replacement generation. + +`foldSurface()` remains the detached full-log replay function for offline validation and reconstruction. It uses the same transitions and agrees with the live manager for every committed prefix without sharing mutable state. + +## Alternatives considered + +**Keep acceptance and projection state separate.** Separate instances appeared to isolate public reads from validation, but callers already receive borrowed surface state and the declared readonly contract prevents ordinary mutation. Duplicating the manager was not a runtime trust boundary. + +**Recompute the public surface from the full log on every access.** This removed duplicate cached state but gave up incremental derivation and made repeated request construction scale with complete session history. + +## Consequences + +- Acceptance, `session.surface`, derived messages, compaction, and workspace context observe one incremental state. +- `Session.surface` exposes no validation method, while its object identity and borrowed readonly node array remain stable. +- A hostile cast can still corrupt borrowed state; JavaScript callers that deliberately bypass the readonly contract remain outside the supported same-process boundary. +- Surface, seed, dispatch-veto, request-reconstruction, compaction, and workspace-context tests exercise the shared manager and detached replay paths. diff --git a/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md new file mode 100644 index 0000000000..6d11c9bb47 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md @@ -0,0 +1,37 @@ +# RFC: 每个会话只使用一个表层管理器 + +Status: implemented + +[English](2026-07-19-use-one-session-surface-manager.md) | 中文 + +## 问题 + +`Session` 曾针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。一个实例负责校验种子事件和追加候选事件,另一个延迟创建的实例则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态,却没有形成独立真源或失败边界。 + +## 决策 + +每个 `Session` 主动创建并只持有一个 `SurfaceManager`。种子事件与追加事件的接纳流程在提交事件之前调用该管理器的 `validateNext()`,`session.surface` 则通过以下只读契约返回同一个对象: + +```ts +export interface SessionSurface { + readonly nodes: readonly number[] + readonly replaceGeneration: number +} +``` + +候选事件校验仍保持原子性。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验失败或提交前 `internal/dispatch` 否决都不会留下虚假节点或替换代数。 + +`foldSurface()` 仍是离线校验与重建使用的分离式完整日志回放函数。它使用相同的状态转换,并且对每个已提交前缀都与活跃管理器一致,但不共享可变状态。 + +## 备选方案 + +**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但调用方取得的本来就是借用的表层状态,声明的只读契约会阻止普通修改。复制管理器并不能构成运行时信任边界。 + +**每次读取都根据完整日志重新计算公共表层。** 该方案能消除重复缓存状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。 + +## 影响 + +- 接纳流程、`session.surface`、派生消息、压缩和工作区上下文观察同一份增量状态。 +- `Session.surface` 不暴露校验方法,同时保持对象标识和借用的只读节点数组稳定。 +- 恶意类型断言仍可破坏借用状态;刻意绕过只读契约的 JavaScript 调用方不属于受支持的同进程边界。 +- 表层、种子、调度否决、请求重建、压缩和工作区上下文测试覆盖共享管理器与分离回放路径。 diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 67404ab49a..db7a9c8a79 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -20,7 +20,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe ## Consequences - Generator quality is the value lever — the generators bias toward small index pools and short strings so collisions and interleavings are common. -- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. +- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index rewrote a completed block. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. - A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect. - Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate. diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 5f05c92317..238f2044ad 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -28,7 +28,7 @@ Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` ``` { kind: 'chunks', chunks: StreamChunk[] } -| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string } | { kind: 'hang' } ``` diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 45348cfd2e..5c9e1014b8 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -6,7 +6,7 @@ Status: implemented Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Decision diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index b2c39047b9..34bebd1194 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -17,7 +17,7 @@ Record two scenarios against the real API, both replayed keyless in the default ### Why a completed turn-1 is required -The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. +The fork backend seeds the child with the parent's **balanced completed-turn prefix**. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. ## Consequences diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index edfcc18585..fd87377fb9 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -29,6 +29,8 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-.jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered @@ -29,8 +31,8 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su ## Testing -Extraction preserved every existing ACP golden byte. The package's `src/` has per-file 100% coverage through a scripted ACP subprocess: harness tests cover every step operation, both expected-error branches, permission selection/fallback/impossible choice, environment forwarding, workspace seeding, and harvest ordering/noise/fallback; suite tests execute replay against committed synthetic fixtures and record against a temporary copy, plus the pure helpers. Two structurally unreachable guards retain reasoned coverage exclusions. The fake agent substitutes the `session/new` cwd into logs, including Darwin's `/var` realpath behavior, matching the real bin. +Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the real launcher by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` directly covers launcher defaults, captures, update waiting, shutdown, and environment/config variants, then covers every scenario step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). ## Consequences -A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. +A new example gets the whole snapshot tier from a scenario table plus fixtures, while an ordinary ACP e2e gets the same tested process/client boundary from one launcher call. The costs: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run — a shape no other package has, stated in its README; and each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard). diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml new file mode 100644 index 0000000000..f111c337dc --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-18-tui-terminal-state-snapshots.md: a1363a521372c11dab8b239cad87df5ff5ca8f22 +2026-07-18-tui-terminal-state-snapshots.zh.md: aa365ba1f2ba17e5bc5409dbdc3736cbc29fe26a diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md new file mode 100644 index 0000000000..a1363a5213 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -0,0 +1,70 @@ +# RFC: Snapshot semantic terminal state for the TUI + +Status: implemented + +English | [中文](2026-07-18-tui-terminal-state-snapshots.zh.md) + +## Problem + +The TUI is a stateful renderer. Its user-visible result depends on ANSI parsing, differential frames, wrapping, scrollback, viewport position, terminal width, focus, cursor state, and each tool's presentation intent. Unit tests that collect `Terminal.write()` fragments can prove event handling, but they cannot prove the final screen a terminal displays. The same screen may also be emitted through different write fragments, so pinning those fragments creates false regressions. + +Component-line snapshots stop before ANSI reaches a terminal and miss cursor movement, clearing, styling, overlay composition, and reflow. Raster screenshots include font and platform rendering noise that is unrelated to the TUI contract. A completed flow built by directly appending plausible session events has another blind spot: it proves the renderer accepts those shapes, not that the production agent loop and tool implementations produce them. + +The TUI therefore needs a deterministic, reviewable representation of terminal state, recorded model journeys that execute the real downstream stack, and a smaller test at the real process and PTY boundary. + +## Decision + +TUI coverage has four complementary layers: + +1. `packages/ui/tui/tests/tui.spec.ts` tests event mapping, input routing, disposal, and error behavior directly. +2. `packages/ui/tui/tests/tui.snapshot.ts` mounts the production TUI against a headless terminal emulator for transient states that a completed session log cannot retain: in-flight streaming, pending tool calls, overlays, expansion, compaction reflow, errors, and shutdown. +3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. +4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. + +The runnable TUI has its own `examples/tui-agent` leaf beside the readline `repl-agent` and `acp-agent` leaves. It reuses the repl-agent backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. + +### Recorded-session replay + +Each example-level scenario directory owns `session.jsonl`, optional child logs `session..jsonl`, and `terminal.golden.txt`. The primary log supplies user-authored `user/message` prompts and the recorded `assistant/chunk` sequence. `dsh-llm-replay` derives one model-call script per session, binds child logs to fresh child sessions, and is the only mocked boundary. The agent loop, bash and filesystem implementations, Code Mode worker, subagent provider, workflow worker, Cordis tools, presenters, and TUI are production implementations. + +The suite rejects a journey when its tool-call sequence differs, an expected event count is missing, a tool result is an error, a turn ends in error, a workflow lifecycle is incomplete, or the live child-session count differs from the fixture set. These assertions prevent an attractive terminal golden from hiding a failed or bypassed production path. + +The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their primary and child JSONL logs and terminal goldens. The deterministic Cordis toolchain keeps an authored complete JSONL script because reliably coercing a live model through five exact tool boundaries and two children is not a stable recording contract. `DSH_SNAPSHOT=refresh` replays every committed script keylessly and rewrites only derived terminal goldens. Plain replay compares without writing, and unknown mode values fail loud. + +### Semantic terminal projection + +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix. + +Each golden projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. + +Every checkpoint enforces theme independence across the complete terminal state: no RGB colors, no palette entries beyond ANSI 0–15, and no explicit background colors. Reverse video remains valid for selection because it uses terminal defaults. Both suites own closed inventories that reject missing scenarios, missing checkpoints, and orphaned golden files. + +### Required scenario matrix + +| Layer | Scenario | Contract pinned | +|---|---|---| +| Recorded journey | Multi-turn conversation | Recorded reasoning/text chunks, two input turns, retained history, token totals, and idle editor state | +| Recorded journey | Todo plan | Real `todo_write` execution, result card, and persistent plan rendering | +| Recorded journey | Bash terminal card | Real local executor output, description, exit status, and completed terminal card | +| Recorded journey | Parallel filesystem reads | Two calls from one assistant message, real file contents, ordering, and separate completed cards | +| Recorded journey | Code Mode | Real `run_code` worker execution, two `tool/code-dispatch` events, captured program output, and completed card | +| Recorded journey | Dynamic workflow | Real workflow worker, phase lifecycle, replayed child session, structured return value, and completed card | +| Recorded journey | Cordis dynamic toolchain | Real mount, Code Mode inspect, direct subagent, workflow child, unmount, and all production presenters | +| Transient state | Streaming and pending advanced calls | In-flight reasoning/text plus pending Code Mode, workflow, and Cordis cards that disappear from completed logs | +| Transient state | Cards, interaction, layout, failure, and shutdown | Collapsed/expanded card families, question validation, compaction replacement, resize reflow, help/errors, cursor restoration, and terminal stop | + +## Alternatives considered + +- **Snapshot raw terminal writes** — rejected because differential rendering may change write boundaries without changing the screen, while cursor and clear sequences are unreadable in review. +- **Snapshot component render lines before terminal output** — rejected because it does not test ANSI parsing, cursor movement, overlays, viewport behavior, or independent components in one frame. +- **Build every completed flow by appending session events** — rejected because a hand-authored event sequence can drift from the agent loop, tool execution, child-session binding, or worker behavior while its presentation test stays green. Direct event construction remains limited to transient renderer states. +- **Reuse ACP stdout goldens as the TUI oracle** — rejected because a recorded model journey is transport-neutral but its presentation is not. TUI scenarios own terminal goldens while using the same JSONL replay vocabulary. +- **Commit raster screenshots** — rejected because fonts, glyph metrics, antialiasing, and host terminal themes make them platform-sensitive and make semantic style changes difficult to review. +- **Use only PTY end-to-end tests** — rejected because raw PTY output is a stream of historical drawing operations, not queryable final state. PTY tests retain the real Loader/input/teardown boundary, while the emulator owns broad state coverage. + +## Consequences + +- Completed advanced snapshots now fail when the real Code Mode, workflow, subagent, filesystem, bash, or Cordis path breaks, rather than accepting a fabricated result event. +- TUI visual regressions produce readable cell-and-style diffs, while JSONL fixtures retain the exact model chunks that made the production path execute. +- The emulator uses xterm's proposed buffer API. An xterm upgrade requires rerunning and reviewing the semantic projection; terminal-specific behavior still needs the PTY smoke. +- Goldens deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes use keyless refresh, while model-journey changes use record mode and review both JSONL and terminal diffs. diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md new file mode 100644 index 0000000000..aa365ba1f2 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -0,0 +1,70 @@ +# RFC: TUI 语义终端状态快照 + +Status: implemented + +[English](2026-07-18-tui-terminal-state-snapshots.md) | 中文 + +## 问题 + +TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析、差分帧、换行、回滚缓冲、视口位置、终端宽度、焦点、光标状态,以及各工具的呈现意图。收集 `Terminal.write()` 片段的单元测试可以验证事件处理,却无法验证终端最终显示的画面。同一画面也可能由不同的写入片段产生,因此固定这些片段会制造误报。 + +组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。直接追加看似合理的会话事件来构造完整流程还存在另一处盲区:这种测试只能证明渲染器接受这些数据形态,无法证明生产环境的 agent loop(智能体循环)和工具实现会生成这些事件。 + +因此,TUI 既需要确定、便于评审的终端状态表示,也需要通过已录制模型流程执行真实下游组件,并保留一项范围更小、覆盖真实进程与 PTY 边界的测试。 + +## 决策 + +TUI 覆盖分为四个互补层次: + +1. `packages/ui/tui/tests/tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。 +2. `packages/ui/tui/tests/tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,覆盖完整会话日志无法保留的瞬态:进行中的流式输出、待完成工具调用、浮层、展开状态、压缩重排、错误和关闭过程。 +3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 +4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 + +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `repl-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 repl-agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 + +### 已录制会话回放 + +每个示例级场景目录都包含 `session.jsonl`、可选的子会话日志 `session..jsonl`,以及 `terminal.golden.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本,并将子日志绑定到新建的子会话;这是测试中唯一的 mock 边界。agent loop、bash 与文件系统实现、Code Mode worker、subagent 提供方、工作流 worker、Cordis 工具、呈现器和 TUI 都使用生产实现。 + +如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,测试都会失败。即使终端金标表面正确,这些断言也能阻止失败或被绕过的生产路径混入结果。 + +真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新;录制模式会重写其主会话与子会话 JSONL 日志以及终端金标。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本,因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本,并且只重写派生的终端金标。普通回放只比较而不写入,未知模式值会快速失败。 + +### 语义终端投影 + +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 + +每份金标把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 + +每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。两套测试都拥有封闭清单,会拒绝缺失的场景、缺失的检查点和遗留金标文件。 + +### 必需场景矩阵 + +| 层次 | 场景 | 固定的契约 | +|---|---|---| +| 已录制流程 | 多轮会话 | 已录制的推理与文本分片、两轮输入、保留历史、token 总量和空闲编辑器状态 | +| 已录制流程 | Todo 计划 | 真实 `todo_write` 执行、结果卡片和持久计划渲染 | +| 已录制流程 | Bash 终端卡片 | 真实本地执行器输出、说明、退出状态和已完成终端卡片 | +| 已录制流程 | 并行文件读取 | 同一条 assistant 消息中的两次调用、真实文件内容、顺序和两个独立完成卡片 | +| 已录制流程 | Code Mode | 真实 `run_code` worker 执行、两条 `tool/code-dispatch` 事件、捕获的程序输出和已完成卡片 | +| 已录制流程 | 动态工作流 | 真实工作流 worker、阶段生命周期、回放的子会话、结构化返回值和已完成卡片 | +| 已录制流程 | Cordis 动态工具链 | 真实挂载、Code Mode 检查、直接 subagent、工作流子会话、卸载和全部生产呈现器 | +| 瞬态 | 流式输出与待完成高级调用 | 进行中的推理和文本,以及完整日志中不会保留的待完成 Code Mode、工作流和 Cordis 卡片 | +| 瞬态 | 卡片、交互、布局、失败和关闭 | 折叠与展开的卡片族、问题校验、压缩替换、尺寸重排、帮助与错误、光标恢复和终端停止 | + +## 曾考虑的替代方案 + +- **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。 +- **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。 +- **通过追加会话事件构造所有完整流程**:不予采纳,因为人工编写的事件序列可能与 agent loop、工具执行、子会话绑定或 worker 行为发生偏差,但呈现测试仍然保持绿色。直接构造事件只用于渲染器瞬态。 +- **复用 ACP stdout 金标作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇,但拥有独立的终端金标。 +- **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。 +- **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。 + +## 后果 + +- 当真实 Code Mode、工作流、subagent、文件系统、bash 或 Cordis 路径损坏时,已完成高级快照会失败,不会继续接受伪造的结果事件。 +- TUI 视觉回归会产生便于阅读的单元格和样式 diff,而 JSONL fixture 会保留触发生产路径的确切模型分片。 +- 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影;终端特有行为仍需由 PTY 冒烟测试覆盖。 +- 金标有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新;模型流程变更使用录制模式,并同时评审 JSONL 与终端 diff。 diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index bf4e85e020..c05ae8d67f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Add isolated subagent providers for Claude Code and Codex. The existing [named-provider seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) and [ACP backend](../../implemented/feature/2026-06-22-acp-subagent-backend.md) establish the process-boundary shape. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. +The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. ## Proposal @@ -12,9 +12,9 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, ignored `request.parent` and `request.agentOptions`, and a random branded agent id. `result` never rejects; child failures map to stop reasons while the original error reaches the logger. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. +Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. ## Verified interface facts (pinned versions) @@ -31,11 +31,11 @@ Both integration surfaces were verified against pinned implementations before th ## Isolation and credentials -Authentication is API-key-only. Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed best-effort on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary values such as `PATH`, `HOME`, `TMPDIR`, locale, and proxy settings, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start` rather than a hand-written auth file. +Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. ## Permission and approval policy -Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with `permission: reject`; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Examples opt into `acceptEdits` or `workspace-write`. Known approval, user-input, and elicitation requests receive the configured answer; unknown methods receive method-not-found and unknown notifications are consumed. No prompt reaches a human, and no child can wait indefinitely for unavailable input. +Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. ## StopReason mapping @@ -45,11 +45,11 @@ Liveness posture, stated explicitly: teardown timing is config, turn duration is ## Testing -Coverage is required at each applicable tier: +Named at every tier per the root AGENTS.md rule, and de-risked up front: -- **Keyless unit/integration:** drive a fake Claude CLI through the real SDK and a scripted Codex app-server through the real wire client. At per-file 100% coverage, exercise round trips, every stop mapping, both cancellation paths and pre-abort, permission policies, unknown messages, spawn failure, reload cleanup, export shape, scrubbed environments, temporary-directory removal, and Codex auth precheck failure. -- **With-key e2e:** each real engine performs file work under `acceptEdits` or `workspace-write`; skips name the missing binary or key and assert no child process remains. -- **Snapshot:** deferred as `TODO(claude-code-subagent-replay)` and `TODO(codex-subagent-replay)` pending the process-specific replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). +- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. +- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. +- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. ## Alternatives considered diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index c4ee6161bd..8e4f4cce13 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, Knip overrides, and snapshot scenario metadata. Most restate package layout, manifest data, aggregate command contents, or fixture files. Each new package or scenario therefore creates avoidable synchronization points. +Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, and Knip overrides. Most restate package layout, manifest data, or aggregate command contents. Each new package therefore creates avoidable synchronization points. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). @@ -16,7 +16,7 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. -Two of the cataloged items need no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright, and `childSessions` can be discovered from each scenario's fixture directory, leaving the scenario table to declare only policy (`recorded`, `hasModelTurn`, `comparesLog`) — and even those track fixture-derivable facts today (`comparesLog` ⟺ the committed log has entries beyond its header line; `recorded` ⟺ `hasModelTurn` with no `replay.override.json` sibling), so each new scenario class keeps adding knobs the fixture directory already answers. +One cataloged item needs no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright. ## Acceptance criteria @@ -25,7 +25,6 @@ Two of the cataloged items need no generator at all: folding the e2e entry glob - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. - `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. -- Snapshot scenarios declare policy, not facts discoverable from their fixture directories. ## Risks diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md deleted file mode 100644 index d94ce11f5c..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ /dev/null @@ -1,40 +0,0 @@ -# RFC: Unify the agent id and the session id - -Status: proposed - -## Problem - -The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced and persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. - -ACP already uses the same value for both identities. They diverge for config-created agents, resumed sessions, and in-process children, but no production path reattaches one live agent to several sessions or drives one session through several agent ids. Stdio keeps `labelBySession` only to recover an agent label from session events, and hooks expose both values for authors to reconcile. - -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no identity-specific reservation state: create and resume use one `AgentCreationTransaction`, and both registry entries use the same final-entry collision rule. Separate ids do not duplicate liveness, rollback, or quiescence machinery. Unification deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle; it also makes the live-agent registry enforce the session identity used by background-task ownership. - -`Session` separately exposes `Session.id` and `Session.header.id` even though construction requires them to match. The durable boundary must validate the duplicate, and consumers must choose between two homes for one fact. - -## Proposal - -Use one id for the agent registry entry and `session.header.id`. `CreateAgentOptions` accepts one identity for both final entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; and `Session` keeps one identity home. Preserve the current transaction, final-entry collision checks, exact-entry detach, rollback, and quiescence; remove only maps and fields whose sole job is translating between the ids. - -The config-driven path must first settle its resume-or-create policy. Today it uses a stable agent label and a fresh UUID-suffixed session id to avoid colliding with an existing durable log on the next run. Under unification it must deliberately resume a fixed id, mint a fresh combined id, or expose that policy; implementation must not choose silently. - -`agent/created` and `agent/disposed` remain outside this proposal. They are publication lifecycle events rather than identity aliases; removing them requires a separate production-consumer audit and decision. - -## Alternatives considered - -**Keep separate routing and log identities.** A stable configured agent label paired with a fresh conversation is a real use of the distinction. If that display or routing identity is required, reject this proposal and enforce session-id uniqueness explicitly instead of hiding the translation in another map. - -## Acceptance criteria - -- Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. -- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence guarantees without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session-id translation. -- The config-driven resume-or-create policy is explicit and covered across a durable restart. -- `agent/created` and `agent/disposed` change only after a separate production-consumer audit. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. - -## Risks - -Unification forecloses a stable actor identity spanning several session logs, including a future handoff or fork that preserves the actor while changing the session. Reintroducing that design would require a new explicit actor identity. It also makes a persisted, possibly client-chosen session id the registry handle and changes every create/resume call site and fixture. - -The config restart policy is the blocking design decision: a fixed combined id may collide with its existing log, while a per-run id gives up the stable configured label. If either independent actor identity or the stable-label/fresh-session pairing is required, reject this proposal and retain the separate ids with an explicit uniqueness guard. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index cca7c57b34..f874784a6d 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -25,6 +25,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | | `BasicCompactService` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented RFC names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | | `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | +| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while Code Mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | | `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | | Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | | Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml new file mode 100644 index 0000000000..e04aed96a6 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-make-jsonrpc-directional.md: 2a9579c53c111887e9d93cf02cc832304a496795 +2026-07-19-make-jsonrpc-directional.zh.md: 4f3793f1b4de3b4f69c4219c10fe5b3b360c8692 diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md new file mode 100644 index 0000000000..2a9579c53c --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -0,0 +1,46 @@ +# RFC: Make JSON-RPC completion and transport directional + +Status: proposed + +English | [中文](2026-07-19-make-jsonrpc-directional.zh.md) + +## Problem + +The JSON-RPC bridge models both endpoints as symmetric peers although the shipped protocol is directional. The TypeScript server accepts requests and emits responses or notifications, but its transport also implements unused outbound requests and inbound notification dispatch. The Python SDK sends requests and receives responses or notifications, but it also queues unused inbound server requests and exposes response helpers. + +`session/prompt` also reports one settled turn through two protocol shapes. The server emits `session.finished` and then returns the constant `{ accepted: true }`; the Python SDK discards that response and waits for the notification to recover the status. Because the response is written only after the handler returns, the notification necessarily precedes the constant response on the same stream. + +The unused halves add pending-request maps, generated IDs, request queues, close-time rejection paths, response helpers, and a second completion waiter without serving a production caller. + +## Proposal + +Specialize each endpoint to its actual role. The TypeScript transport will retain inbound requests, outbound responses, and outbound notifications. The Python client will retain outbound requests and inbound responses or notifications. Delete the opposite-direction request machinery from each side. + +Return the settled outcome directly from `session/prompt` as `{ status, reason }` after `agent.whenIdle()`. Delete `session.finished`, the constant acceptance response, and the Python post-response completion loop. `session.event` and subagent notifications still stream before the response, and durable session events remain the source for final-response reconstruction. + +## Implementation plan + +1. In `packages/ui/jsonrpc/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. +2. In `packages/ui/jsonrpc/src/transport.ts`, replace `JsonRpcTransportPeer` with a server-side notification surface and retain `onRequest()`, `notify()`, `start()`, `flush()`, and `close()`. Remove generated request IDs, the pending-response map, outbound `request()`, inbound response and notification dispatch, and close-time pending-request rejection. Incoming response- and notification-shaped frames will be ignored, while request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. +3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter. +4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message. +5. Replace the symmetric transport-pair cases in `packages/ui/jsonrpc/tests/transport.spec.ts` with raw client-input/server-output coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. + +## Alternatives considered + +**Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer today. + +**Keep `session.finished` for streaming clients.** Turn settlement is not incremental data: the request response already marks the same boundary and follows all earlier notifications on the ordered stream. A second terminal notification creates two representations that clients must reconcile. + +## Acceptance criteria + +- The TypeScript endpoint cannot originate requests or consume notifications. +- The Python endpoint cannot originate notifications or consume server requests. +- `session/prompt` returns the authoritative `ok`, `error`, or `aborted` outcome and reason after turn settlement. +- Session events and subagent lifecycle notifications emitted during the turn arrive before the response. +- Same-session overlap rejection, framing, multibyte input, handler errors, flush, shutdown ordering, and final-response reconstruction retain their behavior. +- TypeScript bridge tests, Python SDK tests, built JSON-RPC coverage, snapshots, and generated API documentation pass. + +## Risks + +This deliberately narrows the pre-release wire protocol. Raw clients listening only for `session.finished`, or embedders using the unused symmetric transport methods, must move to the prompt response. A future server-initiated request requires a new typed protocol addition rather than reusing generic dormant machinery. diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md new file mode 100644 index 0000000000..4f3793f1b4 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -0,0 +1,46 @@ +# RFC: 让 JSON-RPC 完成结果与传输方向单一化 + +Status: proposed + +[English](2026-07-19-make-jsonrpc-directional.md) | 中文 + +## 问题 + +JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。TypeScript 服务端接收请求并发出响应或通知,其传输层却还实现了未使用的出站请求和入站通知分发。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 + +`session/prompt` 还会用两种协议结构报告同一个已结束轮次。服务端先发出 `session.finished`,再返回常量 `{ accepted: true }`;Python SDK 丢弃该响应,转而等待通知以取得状态。响应只有在处理函数返回后才会写入,因此在同一条有序流上,通知必然先于这个常量响应。 + +这些未使用的双向能力引入了待处理请求表、生成 ID、请求队列、关闭时的拒绝路径、响应辅助方法和第二套完成等待逻辑,却没有任何生产调用方使用。 + +## 提案 + +按实际角色收窄两个端点。TypeScript 传输层只保留入站请求、出站响应和出站通知。Python 客户端只保留出站请求以及入站响应或通知。删除两侧与实际方向相反的请求机制。 + +在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 + +## 实施计划 + +1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前或可合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 +2. 在 `packages/ui/jsonrpc/src/transport.ts` 中,用服务端通知接口替换 `JsonRpcTransportPeer`,并保留 `onRequest()`、`notify()`、`start()`、`flush()` 和 `close()`。删除生成的请求 ID、待处理响应表、出站 `request()`、入站响应与通知分发,以及关闭时对待处理请求的拒绝逻辑。入站响应结构和通知结构将被忽略;请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 +4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 +5. 用原始客户端输入与服务端输出覆盖替换 `packages/ui/jsonrpc/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 + +## 备选方案 + +**为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但当前没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 + +**为流式客户端保留 `session.finished`。** 轮次结束不是增量数据:请求响应已经标识同一个边界,并且在有序流中位于先前所有通知之后。第二条终止通知会产生两种结果表示,迫使客户端进行协调。 + +## 验收标准 + +- TypeScript 端点无法发起请求,也不消费通知。 +- Python 端点无法发起通知,也不消费服务端请求。 +- 轮次结束后,`session/prompt` 返回权威的 `ok`、`error` 或 `aborted` 状态及其原因。 +- 轮次中发出的会话事件与 subagent 生命周期通知都先于响应到达。 +- 同一会话的重叠拒绝、分帧、多字节输入、处理器错误、flush、关闭顺序与最终响应重建保持原有行为。 +- TypeScript 桥接测试、Python SDK 测试、构建后 JSON-RPC 覆盖、快照和生成的 API 文档全部通过。 + +## 风险 + +本提案会刻意收窄预发布协议格式。仅监听 `session.finished` 的原始客户端,以及使用未使用对称传输方法的嵌入方,都必须改为读取请求响应。未来若需要服务端发起请求,应新增类型化协议,而不是复用休眠的通用机制。 diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 4c52d0b1a4..266a1d911d 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -1,19 +1,19 @@ # RFC: Prune the unimplemented subagent seam vocabulary -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. ## Problem The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: -- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): every real provider declares `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) builds `{ prompt, parent, signal?, agentOptions? }` and structurally cannot set either; `structured` is produced only by the test mock (`packages/support/subagent-mock`) for its own spec. The service's capability check carries two assert rows whose only exercisers are the rejection tests. +- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests. - **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. ## Proposal -Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the mock's structured branch and its `capabilities`/`structured` config knobs, and the tests that exist to pin the removed surface (the two rejection rows, the spawn absence test, the mock structured specs). Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, and the README rows in `packages/subagent/subagent`, `packages/subagent/subagent-spawn`, `packages/subagent/subagent-fork`, and `packages/support/subagent-mock`. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). +Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the scripted fixture's structured branch and capability knobs, and the tests that exist to pin the removed surface. Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, plus the affected provider READMEs. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). **Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml new file mode 100644 index 0000000000..d2a15cc2c2 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-fold-compaction-package-split.md: 7c7a2da85beb956f8d6c24f813fc33aa350b5c0e +2026-07-19-fold-compaction-package-split.zh.md: 37d75671d57226742608a525ea711b34780e65c6 diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md new file mode 100644 index 0000000000..7c7a2da85b --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md @@ -0,0 +1,37 @@ +# RFC: Fold the single compaction backend into its service package + +Status: rejected — More compaction backends are planned, so the interface and basic implementation packages remain separate. + +English | [中文](2026-07-19-fold-compaction-package-split.zh.md) + +## Problem + +Compaction is split between `@deepseek-ai/dsh-compact`, which owns an abstract two-method service and shared types, and `@deepseek-ai/dsh-compact-basic`, which owns the only complete implementation. Shipped configurations load only the basic package, and no production package independently consumes the interface package except that implementation. + +The split adds a package manifest, README, project boundary, dependency edge, abstract forwarding class, generated catalog entries, and composition wiring without demonstrating backend substitution. The [capability-seam decision](../../implemented/architecture/2026-06-13-capability-seams.md) requires a real interface, implementation, and consumer rather than a preemptive split; the [compaction decision](../../implemented/feature/2026-06-18-compaction-capability-seam.md) records that its independent consumer was deferred. + +## Proposal + +Move the basic implementation into `@deepseek-ai/dsh-compact` and remove `@deepseek-ai/dsh-compact-basic`. Keep `ctx.compact`, `CompactionResult`, the shared transcript and tool-pairing helpers, the existing configuration, and the concrete compaction algorithm in one package. + +Preserve `summarize()` as a protected customization hook. A deployment-specific summarizer can subclass or intercept the existing LLM call without requiring a second capability package. Reintroduce an interface package only when a second complete backend and an independent consumer need substitution. + +Amend the implemented compaction decision and the [recallable-compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) if this proposal is accepted so package ownership has one durable description. + +## Alternatives considered + +**Keep the split because a remote or recall backend may arrive.** A possible future implementation does not justify the current package boundary. Recall adds a consumer of compaction results, not necessarily another implementation, and a remote summarizer can use the protected hook. + +**Move the implementation package name onto the interface package.** Keeping `compact-basic` as the surviving name would make the product service appear to be one optional backend. `compact` is the stable service identity already used by `ctx.compact` and is the clearer single-package owner. + +## Acceptance criteria + +- `@deepseek-ai/dsh-compact-basic` and its workspace/package metadata are removed. +- `@deepseek-ai/dsh-compact` owns the current configuration, plugin class, algorithm, types, events, and shared helpers. +- Existing deployments can load the surviving package with equivalent configuration and model-visible behavior. +- Automatic and manual compaction preserve cancellation, locking, token accounting, tool pairing, durable events, provenance, retry convergence, and transcript rendering. +- Loader composition, unit, runaway-turn, cancellation, snapshot, and real-model compaction tests pass; generated catalogs and module graphs are current. + +## Risks + +This is an intentional pre-release package-name contraction. Embedders loading `@deepseek-ai/dsh-compact-basic` must switch packages, and future backend substitution would require extracting a boundary again. The cost is acceptable only while one complete implementation exists; acceptance should be revisited if a second backend lands first. diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md new file mode 100644 index 0000000000..37d75671d5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md @@ -0,0 +1,37 @@ +# RFC: 将唯一的压缩后端并入服务包 + +Status: rejected — 计划增加更多压缩后端,因此接口包与 basic 实现包继续分离。 + +[English](2026-07-19-fold-compaction-package-split.md) | 中文 + +## 问题 + +压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 + +该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 + +## 提案 + +把 basic 实现移入 `@deepseek-ai/dsh-compact`,并删除 `@deepseek-ai/dsh-compact-basic`。`ctx.compact`、`CompactionResult`、共享 transcript(文本记录)和工具配对辅助方法、现有配置以及具体压缩算法都由一个包负责。 + +保留 `summarize()` 作为受保护的自定义钩子。部署专用的摘要器可以通过继承或拦截现有 LLM(大语言模型)调用完成定制,无需第二个能力包。只有在第二个完整后端与独立消费方确实需要替换实现时,才重新提取接口包。 + +如果本提案获准,应同步修订已实现的压缩决策与[可回忆压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.md),使包所有权只有一处持久说明。 + +## 备选方案 + +**为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 + +**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 + +## 验收标准 + +- 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 +- `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 +- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 +- 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 +- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 + +## 风险 + +这是一项有意实施的预发布包名收缩。加载 `@deepseek-ai/dsh-compact-basic` 的嵌入方必须切换包,未来的后端替换也需要重新提取边界。只有在仍然只有一个完整实现时,这项代价才可接受;如果第二个后端先行落地,应重新评估是否接纳本提案。 diff --git a/docs/testing.md b/docs/testing.md index 8d4f0f554f..cd3b1aa9e8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Use `pnpm run test:snapshot:record` when the model transcript should change; use `pnpm run test:snapshot:refresh` when the committed transcript is still the right mock LLM input and replay goldens need keyless rewrite. Review the golden diff. System-prompt/tool-schema content is pinned by ONE scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. ## Test subprocess launch modes @@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario in the owning example's snapshot suite (`examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/acp-agent` is the primary suite), or states in the PR why none applies. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +Any change affecting an editor-facing transcript, headless event stream, or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f6a2e0625f..dfb97deef3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,7 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -169,7 +169,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `cordis_inspect` -Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. +Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. ```json { @@ -186,6 +186,10 @@ Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: "api", "events" ] + }, + "name": { + "type": "string", + "description": "Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"." } } } @@ -444,7 +448,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/examples/README.md b/examples/README.md index 5db1e18372..abeee1d97e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-demo`](../packages/examples/stdio-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo)) and the [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent @@ -9,17 +9,29 @@ A mock model + echo tool on the stdio chat app — the all-mock skeleton. The le - A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` -- "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter +- "Swap the backend, keep the app" — the only difference from `repl-agent` is the adapter Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. -## coding-agent +## repl-agent -A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. The UI is a terminal readline REPL. +A coding agent with DeepSeek V4, the `read`/`write`/`edit` filesystem tools, the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. -Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. +Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [repl-agent/README.md](repl-agent/README.md) for details. -Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. +Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](repl-agent/README.md#code-mode) for its composition and a sample task. + +## headless-agent + +A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. + +Run with: `pnpm run demo:headless -- "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. + +## tui-agent + +The full-screen terminal sibling of `repl-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. + +Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. ## cordis-agent @@ -29,7 +41,7 @@ Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/R ## acp-agent -An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests. +An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) app — drive it from Zed or any other ACP client. It owns the ACP keyless snapshot suite. Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mode acp` boots the same server in Code Mode via the `code-mode.cordis.yml` overlay. See [acp-agent/README.md](acp-agent/README.md) for the Zed setup and the snapshot-test design. diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 5424be6ab3..000017dd90 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -33,7 +33,7 @@ The editor sets each session's `cwd` to the project it opens, and bash uses that ## Snapshot tests (record-once / replay-deterministic) -This example hosts the ACP snapshot suite. `dsh-llm-replay` reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL, so replay is keyless. Recording runs the real agent and harvests that log; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the full design. +This example hosts the ACP snapshot suite. It replays through `dsh-llm-replay`, which reconstructs model streams from `assistant/chunk` events in each scenario's session JSONL. Recording runs the real ACP agent and harvests its logs; refresh keeps the committed transcript as mock input and rewrites current replay outputs. `replay.override.json` covers throw and hang cases that chunks cannot express, and an optional `workspace/` seeds files. The [snapshot RFC](../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) owns the ACP harness design. ## Permissions and sandboxing diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index b19bc9bfb1..46592e4704 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,173 +1,62 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** - * Boots examples/acp-agent as an ACP subprocess. The key-gated prompt leg - * verifies its filesystem effect; a keyless initialize leg verifies that stdout - * contains only framed JSON-RPC. Each subprocess is disposed in `afterEach`. + * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over + * its stdio, drive it with a real ClientSideConnection, send a real prompt, and + * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns + * and disposes the subprocess in afterEach. Key-gated. + * + * Also asserts stdout purity (only framed JSON-RPC on stdout) — that one runs + * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -// The child runs from a temp cwd, so its bin and config path are absolute. -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// The root tsconfig supplies unbuilt workspace `paths`; making it explicit -// avoids accidental resolution through stale built output. -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } +const DANGER_FULL_ACCESS_ENV = { DSH_PERMISSION_MODE: 'danger-full-access' } -function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - env: { - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - // This suite selects danger-full-access (approval never), so the bridge - // never prompts here; answer cancelled if an unexpected ask arrives. - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined -function hasStdoutLine(out: string[]): boolean { - return out.join('').split('\n').some(line => line.trim().length > 0) -} - -async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise { - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout) - child.stdout.off('data', onData) - child.off('exit', onExit) - child.off('error', onError) - } - const pass = () => { - cleanup() - resolve() - } - const fail = (reason: string) => { - cleanup() - reject(new Error(`${reason}; stderr: ${stderr.join('')}`)) - } - const onData = () => { - if (hasStdoutLine(out)) pass() - } - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`) - } - const onError = (error: Error) => { - fail(`ACP child failed before emitting a stdout frame: ${error.message}`) - } - const timeout = setTimeout(() => { - fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`) - }, timeoutMs) - - child.stdout.on('data', onData) - child.on('exit', onExit) - child.on('error', onError) - onData() - }) -} - afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('acp-agent over real stdio (no key required)', () => { it('emits only framed JSON-RPC on stdout', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. - // A dummy key boots the adapter; this purity test sends no prompt and makes no model call. - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, + // Inspect the launcher's raw-byte tee in addition to driving its SDK client. + // A dummy key lets the deepseek adapter APPLY (it only checks the key is + // present at boot, not valid — the key is used only on a real model call, + // which this purity test never triggers). So this runs WITHOUT real creds. + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_HOME: join(workdir, '.dsh'), - DSH_AGENTS_HOME: join(workdir, '.agents'), + ...DANGER_FULL_ACCESS_ENV, }, }) - const child = spawn(launch.command, launch.args, { - cwd: workdir, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - const out: string[] = [] - const stderr: string[] = [] - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (c: string) => out.push(c)) - child.stderr.on('data', (c: string) => stderr.push(c)) + await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // Send a single initialize request as a newline-delimited JSON-RPC frame. - const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } }) - child.stdin.write(req + '\n') - - try { - await waitForStdoutLine(child, out, stderr, 15_000) - } finally { - child.kill('SIGKILL') - } - - const lines = out.join('').split('\n').filter(l => l.trim().length > 0) + const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0) expect(lines.length).toBeGreaterThan(0) for (const line of lines) { // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON @@ -177,14 +66,28 @@ describe('acp-agent over real stdio (no key required)', () => { }, 30_000) it('session/new succeeds over real stdio (no model call)', async () => { - // Regression guard (this exact RPC crashed a real Zed session with "cannot get property - // \"agents\" without inject"): `session/new` drives the full bridge → - // `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → registry/persistence path, ALL - // of which run from the JSON-RPC read loop outside the bridge plugin's injection scope. + // REGRESSION GUARD (this exact RPC crashed a real Zed session with + // "cannot get property \"agents\" without inject"): `session/new` drives the + // full bridge → `ctx.agents.create({sessionId, meta:{cwd}})` → AgentLoop → + // registry/persistence path, ALL of which run from the JSON-RPC read loop + // OUTSIDE the bridge plugin's injection scope. A lazy `ctx.` read + // on that path throws and the RPC fails with an Internal error — yet the + // call never touches the model, so this reproduces WITHOUT a key. The + // key-gated prompt test below never caught it (it needs real creds); the + // initialize-only purity test never caught it (initialize does not reach + // the factory). This closes that gap: boot the real subprocess and create a + // session, asserting the RPC RESOLVES (not rejects with an inject error). workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // A dummy key lets the deepseek adapter boot (it only checks presence, not // validity, at apply time); no model call is made, so the key is never used. - spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', + ...DANGER_FULL_ACCESS_ENV, + }, + }) const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -197,7 +100,7 @@ describe('acp-agent over real stdio (no key required)', () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => { it('runs a real turn and the agent writes the requested file (verified on disk)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -211,29 +114,34 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the filesystem effect rather than the agent's report. + // Verify the WORLD, not the agent's self-report: read the file from disk. const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') + // And the client saw tool-call activity stream through. const toolCalls = updates.filter(u => u.sessionUpdate === 'tool_call') expect(toolCalls.length).toBeGreaterThan(0) - // Bash execute cards hide rawInput, so `presentCall` uses the exact command - // as the title rather than the bare tool name "bash". + // Tool-call UI quality (the tool owns its presentation): the bash tool's + // `presentCall` sets the title to the exact command (an execute card hides + // rawInput, so the command IS the title) — NOT the bare tool name "bash". + // A `bash` call must therefore carry an execute kind, a non-"bash" title, + // and a string rawInput (the command). `toolCalls` is already narrowed to + // the `tool_call` shape by the filter above, so these fields are reachable. const bashCall = toolCalls.find(u => u.kind === 'execute') expect(bashCall).toBeDefined() if (bashCall === undefined) throw new Error('expected an execute tool_call') expect(typeof bashCall.title).toBe('string') expect(bashCall.title.length).toBeGreaterThan(0) - expect(bashCall.title).not.toBe('bash') - expect(typeof bashCall.rawInput).toBe('string') - // Without the terminal capability, output uses the console-text path. + expect(bashCall.title).not.toBe('bash') // the old, unhelpful title + expect(typeof bashCall.rawInput).toBe('string') // the exact command + // Capability OFF: no terminal _meta — the ```console text path renders. expect((bashCall as { _meta?: unknown })._meta).toBeUndefined() }, 180_000) it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir, env: DANGER_FULL_ACCESS_ENV }) const { client, updates } = spawned // Advertise the Zed `_meta.terminal_output` capability so the bridge emits diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 23eca9dbc0..eb9241cae9 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -53,6 +53,13 @@ const SCENARIOS: Scenario[] = [ // Its prompt and tool-schema sidecars pin the composed header. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'tool-call-turn', hasModelTurn: true, recorded: true }, + { + name: 'parallel-tool-calls', + hasModelTurn: true, + recorded: false, + headerClass: 'fs', + configPath: FS_CONFIG, + }, { name: 'bash-spill', hasModelTurn: true, recorded: false, headerClass: 'fs', configPath: FS_CONFIG }, { name: 'fs-terminal-card', hasModelTurn: true, recorded: true }, { name: 'todo-plan', hasModelTurn: true, recorded: true }, @@ -96,14 +103,15 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. - { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'workflow-run', hasModelTurn: true, recorded: true }, // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and // Cordis plugins require their own request-header pin; the fixture tests deterministic composition. @@ -111,11 +119,17 @@ const SCENARIOS: Scenario[] = [ name: 'advanced-toolchain', hasModelTurn: true, recorded: false, - childSessions: 2, pinsHeader: true, headerClass: 'advanced', configPath: ADVANCED_CONFIG, }, + { + name: 'cordis-inspect-jsdoc', + hasModelTurn: true, + recorded: false, + headerClass: 'advanced', + configPath: ADVANCED_CONFIG, + }, // Prompt-submit blocks are authored keylessly: they persist a rejected turn // and hook events without starting a model step, so their logs still compare. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, @@ -128,9 +142,6 @@ const SCENARIOS: Scenario[] = [ { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true }, - // TODO(hook-snapshot-noise): re-record the PostToolUse block fixtures with a - // self-limiting prompt or hook so one rejected result proves the seam without - // repeated block/retry cycles in the committed JSONL. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true }, { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/cleanup.e2e.ts b/examples/acp-agent/tests/cleanup.e2e.ts new file mode 100644 index 0000000000..1f6e6493e0 --- /dev/null +++ b/examples/acp-agent/tests/cleanup.e2e.ts @@ -0,0 +1,38 @@ +/** Regression coverage for ACP example teardown. */ + +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +let fallbackWorkdir: string | undefined + +afterEach(async () => { + if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true }) + fallbackWorkdir = undefined +}) + +describe('cleanupAcpExampleTest', () => { + it('removes the workspace after process shutdown fails', async () => { + fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-')) + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir)) + .rejects.toMatchObject({ errors: [closeFailure] }) + await expect(access(fallbackWorkdir)).rejects.toThrow() + fallbackWorkdir = undefined + }) + + it('reports process and workspace failures together', async () => { + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(2) + expect((failure as AggregateError).errors[0]).toBe(closeFailure) + }) +}) diff --git a/examples/acp-agent/tests/cleanup.ts b/examples/acp-agent/tests/cleanup.ts new file mode 100644 index 0000000000..28a896334a --- /dev/null +++ b/examples/acp-agent/tests/cleanup.ts @@ -0,0 +1,23 @@ +/** Shared teardown for ACP example tests. */ + +import { rm } from 'node:fs/promises' +import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Close the test agent, then remove its workspace, attempting both operations + * and reporting every failure instead of allowing the later one to mask the + * earlier one. + */ +export async function cleanupAcpExampleTest( + spawned: Pick | undefined, + workdir: string | undefined, +): Promise { + const results: PromiseSettledResult[] = [] + if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')])) + if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })])) + + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed') +} diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index 7e1024c23f..59d6d75caa 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -1,40 +1,49 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { - ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, } from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** - * Exercises the default ACP composition through the real bin and Loader. The - * keyless leg boots sandbox, approval, permission, and bridge services, then - * initializes and opens a session without a model call or runner probe. With a - * key and usable runner, the prompt asserts a prior denial; the model requests - * a wider retry with justification, and a scripted client grants or rejects it. - * The filesystem must show that only the granted retry ran. Missing credentials - * or runner support self-skip; real denial markers remain on sandbox e2e tiers. + * The default ACP composition (`cordis.yml`) end to end. + * + * Keyless smoke: boot the REAL `cordis.yml` through the `dsh-acp-agent` bin as + * an ACP subprocess and drive initialize + session/new — the real-Loader-path + * guard (postmortem 0001) for THIS tree's export shapes, which now include the + * sandbox executor AND the approval service. No prompt is sent, so neither the + * model nor a sandbox runner is ever exercised. + * + * With-key escalation flow (self-skips without DEEPSEEK_API_KEY or a usable + * platform runner): a scripted ACP client plays the human. The prompt asserts + * a prior denial (the organic denial→marker path lives on the sandbox e2e + * legs and unit tiers), the real model escalates with `sandbox_permissions` + + * `justification`, the bridge prompts THIS client over + * `session/request_permission`, the client answers `allow-once`, and the + * retried write must land ON DISK (world-verified) — under the granted mode, + * a temp-dir session cwd is writable either way. */ -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// The subprocess runs from a temp cwd outside the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves in src mode (see examples/AGENTS.md). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} -// Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with -// SANDBOX_UNAVAILABLE instead of producing the denial this flow requires. +// A usable confining runner, probed the same way the executor suites do: +// bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict +// attempt would fail closed (SANDBOX_UNAVAILABLE) instead of producing the +// denial this flow starts from. const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: 5_000, stdio: 'ignore', @@ -45,72 +54,49 @@ const hasSeatbelt = process.platform === 'darwin' && spawnSync('sandbox-exec', [ }).status === 0 const hasRunner = hasBwrap || hasSeatbelt -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] +interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] - stderr: string[] } /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function spawnAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY || 'sk-dummy-for-boot' }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] +function launchExampleAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { const permissionRequests: RequestPermissionRequest[] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd, + // A dummy key lets the adapter boot keylessly; live tests carry the real key. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) - // An unexpected prompt shape cancels without granting. + // The scripted human: pick the requested option when the prompt offers + // it; an unexpected prompt shape cancels (fail closed, never grants). if (option === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, permissionRequests, stderr } + return Object.assign(launched, { permissionRequests }) } let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - if (spawned !== undefined && spawned.child.exitCode === null) spawned.child.kill('SIGKILL') + const ownedSpawned = spawned + const ownedWorkdir = workdir spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client } = spawned + // A dummy key boots the adapter; no prompt is ever sent, so no model call + // and no sandbox runner probe happen. This drives the fiber tree the same + // way an editor would, which is what catches a broken export/inject shape. const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) expect(init.protocolVersion).toBe(PROTOCOL_VERSION) const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] }) @@ -119,14 +105,18 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa it('advertises model and Permissions selects and honors a permission switch without a model call', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + // This tree composes the permission presets over bash-sandbox + approval → + // ONE select advertises, current from the configured default preset. const created = await client.newSession({ cwd: workdir, mcpServers: [] }) const advertised = created.configOptions ?? [] const modelValue = JSON.stringify(['deepseek', 'deepseek-v4-flash']) expect(advertised.map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['model', modelValue], ['permission', 'workspace-write']]) + // A switch responds with the COMPLETE refreshed state (the spec contract), + // and the new current survives in the response of a second switch. const afterFullAccess = await client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'danger-full-access', }) @@ -137,6 +127,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa }) expect((again.configOptions ?? []).map(option => [option.id, 'currentValue' in option ? option.currentValue : undefined])) .toEqual([['model', modelValue], ['permission', 'danger-full-access']]) + // An out-of-vocabulary value is a protocol error, never a silent default. await expect(client.setSessionConfigOption({ sessionId: created.sessionId, configId: 'permission', value: 'plan', })).rejects.toThrow(/unknown permission value/) @@ -146,7 +137,7 @@ describe('default sandbox composition keyless smoke (real cordis.yml via the Loa describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox composition e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnAcpAgent(workdir, 'allow-once') + spawned = launchExampleAcpAgent(workdir, 'allow-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -158,11 +149,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) - // Verify the filesystem, not the model's report. + // The WORLD: the approved escalated retry landed the write. const proof = await readFile(join(workdir, 'escalated.txt'), 'utf8') expect(proof).toContain('ACP_ESCALATION_OK') - // Verify that ACP carried the grant with only one-shot choices. + // The CHANNEL: the grant came through a real session/request_permission + // prompt attached to the escalating tool call, offering exactly the + // one-shot options. expect(permissionRequests.length).toBeGreaterThan(0) const prompt = permissionRequests[0] if (prompt === undefined) throw new Error('expected a permission request') @@ -173,7 +166,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnAcpAgent(workdir, 'reject-once') + spawned = launchExampleAcpAgent(workdir, 'reject-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -185,8 +178,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('default sandbox co }) expect(['end_turn', 'max_tokens']).toContain(res.stopReason) + // The WORLD: rejected means the file never appeared. await expect(readFile(join(workdir, 'refused.txt'), 'utf8')).rejects.toThrow() - // Distinguish a user rejection from a missing approval channel. + // And the rejection really flowed through a prompt (not a missing channel). expect(permissionRequests.length).toBeGreaterThan(0) }, 240_000) }) diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index 68dc2648bf..528823f3c5 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,21 +1,15 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' -import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { mkdtemp, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is @@ -24,61 +18,21 @@ import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' * The test owns and disposes the ACP subprocess. */ -const binScript = fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -function spawnAcpAgent(cwd: string): Spawned { - const launch = resolveExampleLaunch({ - srcBin: binScript, - configArgs: ['--config', configPath], - tsconfigPath: repoTsconfig, - env: { DSH_PERMISSION_MODE: 'danger-full-access' }, - }) - const child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { @@ -90,7 +44,11 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { DSH_PERMISSION_MODE: 'danger-full-access' }, + }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index 45d5628762..ce3fe8acef 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index fb5b48c70c..b8acef973c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -44,10 +44,12 @@ declare const tools: { /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ justification?: string; }): Promise; - /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. */ + /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */ cordis_inspect(args: { /** Limit the report to one section. Omit for all sections. */ what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; + /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ + name?: string; }): Promise; /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ cordis_mount(args: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json index 57e78b6345..978819fa1f 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json @@ -47,7 +47,7 @@ }, { "name": "cordis_inspect", - "description": "Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.", + "description": "Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.", "parameters": { "type": "object", "properties": { @@ -62,6 +62,10 @@ "api", "events" ] + }, + "name": { + "type": "string", + "description": "Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"." } } } diff --git a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl index ddd1f06a55..9782b0fd7d 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/session.jsonl @@ -10,7 +10,7 @@ {"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","name":"bash","arguments":"{\"command\":\"node -e \\\"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\\\"\",\"description\":\"Print large deterministic output\"}"}} -{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-ad78080217cd/dccd97e3c558-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_spill","content":[{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-8d519f752b89/93e1b6e8dc7e-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl index ad6c396981..d9d2632bd0 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","kind":"execute","status":"in_progress","rawInput":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","content":[{"type":"content","content":{"type":"text","text":"Print large deterministic output"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 7b2dbd604c..4111ecc8de 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json new file mode 100644 index 0000000000..0f40e9d8b6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/input.json @@ -0,0 +1,12 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndCancel", + "text": "Run two shell commands: wait for cancellation, then write skipped.txt.", + "afterUpdate": "tool_call", + "waitForToolCallUpdate": "call_skipped" + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json new file mode 100644 index 0000000000..c0aa7730d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/replay.override.json @@ -0,0 +1,15 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } }, + { "type": "block-start", "index": 1, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" }, + { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 10 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl new file mode 100644 index 0000000000..e6d18515a6 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl @@ -0,0 +1,20 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784437195072,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784437195072,"data":{"content":[{"type":"text","text":"Run two shell commands: wait for cancellation, then write skipped.txt."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784437195076,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}} +{"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}} +{"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}} +{"type":"tool/result","seq":14,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}} +{"type":"tool/result","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call skipped because the step was aborted before execution"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1784437195090,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1784437195090,"data":{"turn":1,"reason":{"kind":"aborted","reason":"session/cancel"}}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl new file mode 100644 index 0000000000..11178b9bc7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.golden.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: tool call skipped because the step was aborted before execution\n```"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index d31c8fdcfb..4146e8804d 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index 31ec5df39a..f3bd0b345c 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl index fcf219e5ea..3d25d176ac 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index 1aca0c6586..47dc73536f 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json new file mode 100644 index 0000000000..62df391622 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl new file mode 100644 index 0000000000..94ea8a8fec --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783951000000,"cwd":"/tmp/cordis-inspect-jsdoc"} +{"type":"turn/start","seq":0,"time":1784449176717,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1784449176718,"data":{"content":[{"type":"text","text":"Inspect the exact tools service API and tools/pre-execute event with cordis_inspect, then reply with exactly CORDIS_INSPECT_JSDOC_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1784449176720,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1784449176720,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783951000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783951000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-api","name":"cordis_inspect","argumentsDelta":"{\"what\":\"api\",\"name\":\"tools\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} +{"type":"tool/result","seq":11,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1784449176732,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1784449176733,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"inspect-tools-event","name":"cordis_inspect","argumentsDelta":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","name":"cordis_inspect","arguments":"{\"what\":\"events\",\"name\":\"tools/pre-execute\"}"}} +{"type":"tool/result","seq":21,"time":1784449176734,"data":{"turn":1,"step":2,"callId":"inspect-tools-event","content":[{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}],"isError":false},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":22,"time":1784449176734,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":23,"time":1784449176735,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":24,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CORDIS_INSPECT_JSDOC_OK"}}} +{"type":"assistant/chunk","seq":26,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"type":"assistant/chunk","seq":27,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":28,"time":1784449176735,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1784449176735,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1784449176735,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":31,"time":1784449176735,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl new file mode 100644 index 0000000000..321c5499a2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.golden.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"Inspect cordis runtime: api: tools","kind":"read","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n }\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export type ContextEnvelope = 'context' | 'raw';\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export interface InjectOptions extends SendOptions {\n envelope?: ContextEnvelope;\n meta?: JsonValue;\n }\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export type ToolExecuteReturn = ContentBlock[] | {\n content: ContentBlock[];\n meta?: unknown;\n };\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: unknown;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"Inspect cordis runtime: events: tools/pre-execute","kind":"read","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## events\n- tools/pre-execute [waterfall] — Allow, deny, or ask before dispatch.\n /**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */\n 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\nwaterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json index eea32f25ca..cfa0d84227 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json +++ b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json @@ -1,3 +1,3 @@ [ - { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH", "status": 401 } + { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" } ] diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index 484b241427..540eb2338a 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index b420fac975..2a33a4e15e 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"19974166-a6ad-4f46-bef8-ce7d6bda3214","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"19974166-a6ad-4f46-bef8-ce7d6bda3214","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"4ba41d82-153a-4587-8c22-dd35783c3d87","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"4ba41d82-153a-4587-8c22-dd35783c3d87","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index 9180f2426a..92743b8133 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 4b45911a66..0e19158f2c 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"564d6e1b-4330-42bd-a646-461e7a6c2d1a","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"564d6e1b-4330-42bd-a646-461e7a6c2d1a","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"84c78b9b-8955-4d4f-8dad-824dcac4d9ed","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"84c78b9b-8955-4d4f-8dad-824dcac4d9ed","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index 871f3e3281..c8a9b320f0 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index df664a96b4..fab47cc857 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index dda4f70968..d92ed5520b 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index fde77464d8..44ce1184e9 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index d6b2e00b1e..712e8e5c3b 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index b35af2c650..d06162a005 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index c5dcf0a7b0..270d1ace7c 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index 7969eeba3b..1cac540a29 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index 011175a871..fb4f7cbbc5 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,2 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json index 3d44990f9b..fac587034a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + { "op": "prompt", "text": "Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop." } ] } diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index 07bd28c2cd..be1d70a853 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -1,752 +1,177 @@ -{"type":"session","version":0,"id":"4da131bc-e9b8-4228-9d27-83ac4d109ef6","createdAt":1783352177362,"cwd":"/tmp/acp-snap-cwd-t5Q4CC"} -{"type":"turn/start","seq":0,"time":1783352177366,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352177367,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352177368,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352177372,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352178017,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352178018,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352178131,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352178159,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352178160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352178160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":12,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":13,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":14,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":15,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":16,"time":1783352178187,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":17,"time":1783352178188,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":18,"time":1783352178216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":19,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":20,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":21,"time":1783352178245,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":22,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":23,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":24,"time":1783352178246,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":25,"time":1783352178275,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":26,"time":1783352178276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":27,"time":1783352178276,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":28,"time":1783352178359,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":30,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":31,"time":1783352178360,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":32,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":33,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":34,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":35,"time":1783352178395,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352178416,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":37,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":38,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":39,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":40,"time":1783352178417,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":41,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":42,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":44,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352178473,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":46,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":48,"time":1783352178500,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":49,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":50,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":51,"time":1783352178501,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":52,"time":1783352178531,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":53,"time":1783352178532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":54,"time":1783352178532,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":55,"time":1783352178560,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":56,"time":1783352178591,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":57,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":58,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2879,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":23}}}} -{"type":"assistant/chunk","seq":59,"time":1783352178592,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":60,"time":1783352178594,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2879,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":23}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59],"surfaceOp":"append"} -{"type":"tool/call","seq":61,"time":1783352178594,"data":{"turn":1,"step":1,"callId":"call_00_qcIzLImnOm5qiKOBJUqY5047","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":62,"time":1783352178614,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":63,"time":1783352178624,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":9.49630100000013}} -{"type":"tool/result","seq":64,"time":1783352178625,"data":{"turn":1,"step":1,"callId":"call_00_qcIzLImnOm5qiKOBJUqY5047","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[61],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1783352178625,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":66,"time":1783352178626,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":67,"time":1783352179685,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":68,"time":1783352179685,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":69,"time":1783352179799,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":70,"time":1783352179828,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":72,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":73,"time":1783352179856,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":74,"time":1783352179885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":75,"time":1783352179885,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":76,"time":1783352179913,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} -{"type":"assistant/chunk","seq":77,"time":1783352179914,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} -{"type":"assistant/chunk","seq":78,"time":1783352179942,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":79,"time":1783352179970,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"rer"}}} -{"type":"assistant/chunk","seq":80,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"un"}}} -{"type":"assistant/chunk","seq":81,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":82,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":83,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summary"}}} -{"type":"assistant/chunk","seq":84,"time":1783352179971,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":85,"time":1783352179999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":86,"time":1783352179999,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":87,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":88,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":89,"time":1783352180000,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" again"}}} -{"type":"assistant/chunk","seq":90,"time":1783352180028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":91,"time":1783352180028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":92,"time":1783352180029,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":93,"time":1783352180055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":94,"time":1783352180055,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summarizes"}}} -{"type":"assistant/chunk","seq":95,"time":1783352180083,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":96,"time":1783352180122,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" we"}}} -{"type":"assistant/chunk","seq":97,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} -{"type":"assistant/chunk","seq":98,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" doing"}}} -{"type":"assistant/chunk","seq":99,"time":1783352180141,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":100,"time":1783352180226,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":101,"time":1783352180226,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":102,"time":1783352180227,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":103,"time":1783352180227,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":104,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":105,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":106,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":107,"time":1783352180255,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":108,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":109,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":110,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":111,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":112,"time":1783352180283,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":113,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":114,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":115,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":116,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":117,"time":1783352180343,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":118,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":119,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"Run"}}} -{"type":"assistant/chunk","seq":120,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" echo"}}} -{"type":"assistant/chunk","seq":121,"time":1783352180372,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":122,"time":1783352180400,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":123,"time":1783352180401,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":124,"time":1783352180401,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":" command"}}} -{"type":"assistant/chunk","seq":125,"time":1783352180428,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":126,"time":1783352180429,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":127,"time":1783352180488,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by policy, which says \"rerun with a summary instead\". Let me try again with a description that summarizes what we're doing."}}}} -{"type":"assistant/chunk","seq":128,"time":1783352180488,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}}}} -{"type":"assistant/chunk","seq":129,"time":1783352180489,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":178,"outputTokens":99,"cacheReadTokens":2816,"reasoningTokens":32}}}} -{"type":"assistant/chunk","seq":130,"time":1783352180489,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1783352180489,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by policy, which says \"rerun with a summary instead\". Let me try again with a description that summarizes what we're doing."},{"type":"tool-call","id":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":178,"outputTokens":99,"cacheReadTokens":2816,"reasoningTokens":32}},"sourceEventSeqs":[67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1783352180489,"data":{"turn":1,"step":2,"callId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO command\"}"}} -{"type":"hook/invoked","seq":133,"time":1783352180524,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":134,"time":1783352180530,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.622174999999515}} -{"type":"tool/result","seq":135,"time":1783352180531,"data":{"turn":1,"step":2,"callId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[132],"surfaceOp":"append"} -{"type":"step/end","seq":136,"time":1783352180531,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":137,"time":1783352180531,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":138,"time":1783352181379,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":139,"time":1783352181379,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":140,"time":1783352181496,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":141,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":142,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":143,"time":1783352181524,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":144,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":145,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":146,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":147,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":148,"time":1783352181553,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" slightly"}}} -{"type":"assistant/chunk","seq":149,"time":1783352181582,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":150,"time":1783352181582,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":151,"time":1783352181583,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":152,"time":1783352181668,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":153,"time":1783352181668,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":154,"time":1783352181701,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":155,"time":1783352181702,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":156,"time":1783352181702,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":157,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":158,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":159,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":160,"time":1783352181726,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":161,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":162,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":163,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":164,"time":1783352181758,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":165,"time":1783352181788,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":166,"time":1783352181788,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":167,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":168,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":169,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":170,"time":1783352181816,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":171,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":172,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":173,"time":1783352181844,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":174,"time":1783352181845,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":175,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":176,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":177,"time":1783352181874,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":178,"time":1783352181905,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":179,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool keeps rejecting. Let me try a slightly different description."}}}} -{"type":"assistant/chunk","seq":180,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":181,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":173,"outputTokens":80,"cacheReadTokens":2944,"reasoningTokens":13}}}} -{"type":"assistant/chunk","seq":182,"time":1783352181933,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":183,"time":1783352181934,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The tool keeps rejecting. Let me try a slightly different description."},{"type":"tool-call","id":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":173,"outputTokens":80,"cacheReadTokens":2944,"reasoningTokens":13}},"sourceEventSeqs":[138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182],"surfaceOp":"append"} -{"type":"tool/call","seq":184,"time":1783352181934,"data":{"turn":1,"step":3,"callId":"call_00_VumDDhhB4n5507EUXq650912","name":"bash","arguments":"{\"description\": \"Print HELLO to stdout\", \"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":185,"time":1783352181945,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":186,"time":1783352181953,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.827785000000404}} -{"type":"tool/result","seq":187,"time":1783352181953,"data":{"turn":1,"step":3,"callId":"call_00_VumDDhhB4n5507EUXq650912","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[184],"surfaceOp":"append"} -{"type":"step/end","seq":188,"time":1783352181953,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":189,"time":1783352181953,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":190,"time":1783352182452,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":191,"time":1783352182452,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":192,"time":1783352182586,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":193,"time":1783352182611,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":194,"time":1783352182640,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":195,"time":1783352182641,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" my"}}} -{"type":"assistant/chunk","seq":196,"time":1783352182641,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" attempts"}}} -{"type":"assistant/chunk","seq":197,"time":1783352182668,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":198,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":199,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":200,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":201,"time":1783352182669,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":202,"time":1783352182697,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" very"}}} -{"type":"assistant/chunk","seq":203,"time":1783352182701,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" short"}}} -{"type":"assistant/chunk","seq":204,"time":1783352182701,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":205,"time":1783352182729,"data":{"turn":1,"step":4,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":206,"time":1783352182787,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":207,"time":1783352182787,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":208,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":209,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":210,"time":1783352182816,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":211,"time":1783352182817,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":212,"time":1783352182844,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":213,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":214,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":215,"time":1783352182845,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":216,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":217,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":218,"time":1783352182873,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":219,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":220,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":221,"time":1783352182905,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":222,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":223,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":224,"time":1783352182930,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":225,"time":1783352182931,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":226,"time":1783352182960,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":227,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":228,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":229,"time":1783352182961,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":230,"time":1783352182987,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":231,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The policy keeps rejecting my attempts. Let me try a very short description."}}}} -{"type":"assistant/chunk","seq":232,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":233,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":149,"outputTokens":80,"cacheReadTokens":3072,"reasoningTokens":15}}}} -{"type":"assistant/chunk","seq":234,"time":1783352183049,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":235,"time":1783352183049,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The policy keeps rejecting my attempts. Let me try a very short description."},{"type":"tool-call","id":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":149,"outputTokens":80,"cacheReadTokens":3072,"reasoningTokens":15}},"sourceEventSeqs":[190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234],"surfaceOp":"append"} -{"type":"tool/call","seq":236,"time":1783352183050,"data":{"turn":1,"step":4,"callId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","name":"bash","arguments":"{\"description\": \"echo HELLO\", \"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":237,"time":1783352183069,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:4","matcher":"bash"}} -{"type":"hook/result","seq":238,"time":1783352183077,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.41934399999991}} -{"type":"tool/result","seq":239,"time":1783352183077,"data":{"turn":1,"step":4,"callId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[236],"surfaceOp":"append"} -{"type":"step/end","seq":240,"time":1783352183078,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":241,"time":1783352183078,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":242,"time":1783352183709,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":243,"time":1783352183709,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"H"}}} -{"type":"assistant/chunk","seq":244,"time":1783352183821,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"mm"}}} -{"type":"assistant/chunk","seq":245,"time":1783352183847,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":246,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":247,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":248,"time":1783352183848,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" keeps"}}} -{"type":"assistant/chunk","seq":249,"time":1783352183876,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" rejecting"}}} -{"type":"assistant/chunk","seq":250,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":251,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":252,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":253,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":254,"time":1783352183877,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" without"}}} -{"type":"assistant/chunk","seq":255,"time":1783352183907,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":256,"time":1783352183908,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}} -{"type":"assistant/chunk","seq":257,"time":1783352183908,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" field"}}} -{"type":"assistant/chunk","seq":258,"time":1783352183936,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":259,"time":1783352183936,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} -{"type":"assistant/chunk","seq":260,"time":1783352183967,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":261,"time":1783352183992,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":262,"time":1783352183993,"data":{"turn":1,"step":5,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":263,"time":1783352184053,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":264,"time":1783352184053,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":265,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":266,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":267,"time":1783352184082,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":268,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":269,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":270,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":271,"time":1783352184111,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":272,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":273,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":274,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":275,"time":1783352184140,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":276,"time":1783352184168,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":277,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Hmm, the policy keeps rejecting. Let me try without the description field, just the command."}}}} -{"type":"assistant/chunk","seq":278,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}}}} -{"type":"assistant/chunk","seq":279,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":125,"outputTokens":66,"cacheReadTokens":3200,"reasoningTokens":20}}}} -{"type":"assistant/chunk","seq":280,"time":1783352184233,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":281,"time":1783352184233,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Hmm, the policy keeps rejecting. Let me try without the description field, just the command."},{"type":"tool-call","id":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":125,"outputTokens":66,"cacheReadTokens":3200,"reasoningTokens":20}},"sourceEventSeqs":[242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280],"surfaceOp":"append"} -{"type":"tool/call","seq":282,"time":1783352184233,"data":{"turn":1,"step":5,"callId":"call_00_0IuOIk6iuG6ZesROSyAM3669","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}} -{"type":"hook/invoked","seq":283,"time":1783352184234,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:5","matcher":"bash"}} -{"type":"hook/result","seq":284,"time":1783352184243,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.810661999999866}} -{"type":"tool/result","seq":285,"time":1783352184243,"data":{"turn":1,"step":5,"callId":"call_00_0IuOIk6iuG6ZesROSyAM3669","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[282],"surfaceOp":"append"} -{"type":"step/end","seq":286,"time":1783352184243,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":287,"time":1783352184244,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":288,"time":1783352185025,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":289,"time":1783352185025,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":290,"time":1783352185125,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":291,"time":1783352185156,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":292,"time":1783352185157,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" consistently"}}} -{"type":"assistant/chunk","seq":293,"time":1783352185157,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":294,"time":1783352185188,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":295,"time":1783352185188,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":296,"time":1783352185212,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":297,"time":1783352185241,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":298,"time":1783352185242,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":299,"time":1783352185242,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":300,"time":1783352185270,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" says"}}} -{"type":"assistant/chunk","seq":301,"time":1783352185270,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":302,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"rer"}}} -{"type":"assistant/chunk","seq":303,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"un"}}} -{"type":"assistant/chunk","seq":304,"time":1783352185271,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":305,"time":1783352185299,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":306,"time":1783352185299,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" summary"}}} -{"type":"assistant/chunk","seq":307,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":308,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":309,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":310,"time":1783352185300,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":311,"time":1783352185328,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":312,"time":1783352185329,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":313,"time":1783352185329,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":314,"time":1783352185358,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":315,"time":1783352185390,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":316,"time":1783352185391,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":317,"time":1783352185391,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":318,"time":1783352185418,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"which"}}} -{"type":"assistant/chunk","seq":319,"time":1783352185419,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" would"}}} -{"type":"assistant/chunk","seq":320,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":321,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":322,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":323,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":324,"time":1783352185448,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":325,"time":1783352185449,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\")"}}} -{"type":"assistant/chunk","seq":326,"time":1783352185476,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":327,"time":1783352185477,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":328,"time":1783352185477,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" blocked"}}} -{"type":"assistant/chunk","seq":329,"time":1783352185506,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":330,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":331,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":332,"time":1783352185535,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":333,"time":1783352185536,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":334,"time":1783352185563,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":335,"time":1783352185564,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} -{"type":"assistant/chunk","seq":336,"time":1783352185564,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":337,"time":1783352185593,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" maybe"}}} -{"type":"assistant/chunk","seq":338,"time":1783352185594,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":339,"time":1783352185594,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} -{"type":"assistant/chunk","seq":340,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":341,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} -{"type":"assistant/chunk","seq":342,"time":1783352185623,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":343,"time":1783352185652,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":344,"time":1783352185652,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":345,"time":1783352185680,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} -{"type":"assistant/chunk","seq":346,"time":1783352185680,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" approach"}}} -{"type":"assistant/chunk","seq":347,"time":1783352185713,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":348,"time":1783352185738,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" satisfy"}}} -{"type":"assistant/chunk","seq":349,"time":1783352185767,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":350,"time":1783352185768,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":351,"time":1783352185797,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":352,"time":1783352185801,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"Wait"}}} -{"type":"assistant/chunk","seq":353,"time":1783352185826,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":354,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":355,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":356,"time":1783352185827,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" explicitly"}}} -{"type":"assistant/chunk","seq":357,"time":1783352185854,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":358,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":359,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":360,"time":1783352185855,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":361,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"run"}}} -{"type":"assistant/chunk","seq":362,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} -{"type":"assistant/chunk","seq":363,"time":1783352185884,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":364,"time":1783352185914,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" echo"}}} -{"type":"assistant/chunk","seq":365,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":366,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":367,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":368,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":369,"time":1783352185915,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":370,"time":1783352185942,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":371,"time":1783352185973,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"Report"}}} -{"type":"assistant/chunk","seq":372,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":373,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":374,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":375,"time":1783352186002,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" you"}}} -{"type":"assistant/chunk","seq":376,"time":1783352186031,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} -{"type":"assistant/chunk","seq":377,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":378,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":379,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":380,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":381,"time":1783352186032,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":382,"time":1783352186060,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":383,"time":1783352186060,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":384,"time":1783352186061,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":385,"time":1783352186061,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":386,"time":1783352186089,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":387,"time":1783352186139,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":388,"time":1783352186146,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":389,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":390,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":391,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":392,"time":1783352186147,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":393,"time":1783352186175,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":" quotes"}}} -{"type":"assistant/chunk","seq":394,"time":1783352186175,"data":{"turn":1,"step":6,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":395,"time":1783352186233,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":396,"time":1783352186233,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":397,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":398,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":399,"time":1783352186262,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":400,"time":1783352186290,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":401,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":402,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":403,"time":1783352186291,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":404,"time":1783352186320,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":405,"time":1783352186320,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" the"}}} -{"type":"assistant/chunk","seq":406,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" string"}}} -{"type":"assistant/chunk","seq":407,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":408,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":409,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":410,"time":1783352186353,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":411,"time":1783352186409,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":412,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":413,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":414,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":415,"time":1783352186410,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":416,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":417,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":418,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":419,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"HE"}}} -{"type":"assistant/chunk","seq":420,"time":1783352186436,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":421,"time":1783352186437,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":422,"time":1783352186465,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":423,"time":1783352186465,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":424,"time":1783352186494,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":425,"time":1783352186526,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool is consistently being rejected by policy. The policy says \"rerun with a summary instead\" - it seems the content of the output (which would be \"HELLO\") is being blocked. Let me try a different approach - maybe I need to use a different command or approach to satisfy the policy.\n\nWait, the user explicitly asked me to \"run exactly: echo HELLO\" and \"Report the tool result you got back verbatim\". Let me try `echo \"HELLO\"` with quotes."}}}} -{"type":"assistant/chunk","seq":426,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}}}} -{"type":"assistant/chunk","seq":427,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":215,"outputTokens":176,"cacheReadTokens":3200,"reasoningTokens":106}}}} -{"type":"assistant/chunk","seq":428,"time":1783352186527,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":429,"time":1783352186527,"data":{"turn":1,"step":6,"content":[{"type":"reasoning","text":"The tool is consistently being rejected by policy. The policy says \"rerun with a summary instead\" - it seems the content of the output (which would be \"HELLO\") is being blocked. Let me try a different approach - maybe I need to use a different command or approach to satisfy the policy.\n\nWait, the user explicitly asked me to \"run exactly: echo HELLO\" and \"Report the tool result you got back verbatim\". Let me try `echo \"HELLO\"` with quotes."},{"type":"tool-call","id":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":215,"outputTokens":176,"cacheReadTokens":3200,"reasoningTokens":106}},"sourceEventSeqs":[288,289,290,291,292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391,392,393,394,395,396,397,398,399,400,401,402,403,404,405,406,407,408,409,410,411,412,413,414,415,416,417,418,419,420,421,422,423,424,425,426,427,428],"surfaceOp":"append"} -{"type":"tool/call","seq":430,"time":1783352186527,"data":{"turn":1,"step":6,"callId":"call_00_mGqWmySh60rWKNcyVBFk2747","name":"bash","arguments":"{\"description\": \"Echo the string HELLO\", \"command\": \"echo \\\"HELLO\\\"\"}"}} -{"type":"hook/invoked","seq":431,"time":1783352186538,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:6","matcher":"bash"}} -{"type":"hook/result","seq":432,"time":1783352186545,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:6","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.910448999999062}} -{"type":"tool/result","seq":433,"time":1783352186545,"data":{"turn":1,"step":6,"callId":"call_00_mGqWmySh60rWKNcyVBFk2747","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[430],"surfaceOp":"append"} -{"type":"step/end","seq":434,"time":1783352186545,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":435,"time":1783352186545,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":436,"time":1783352187156,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":437,"time":1783352187156,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":438,"time":1783352187287,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":439,"time":1783352187316,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":440,"time":1783352187317,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" blocking"}}} -{"type":"assistant/chunk","seq":441,"time":1783352187317,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":442,"time":1783352187345,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":443,"time":1783352187345,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":444,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":445,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":446,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":447,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":448,"time":1783352187374,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" -"}}} -{"type":"assistant/chunk","seq":449,"time":1783352187403,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} -{"type":"assistant/chunk","seq":450,"time":1783352187403,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":451,"time":1783352187432,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":452,"time":1783352187461,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":453,"time":1783352187461,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":454,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":455,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":456,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":457,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":458,"time":1783352187491,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":459,"time":1783352187519,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} -{"type":"assistant/chunk","seq":460,"time":1783352187519,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"which"}}} -{"type":"assistant/chunk","seq":461,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" might"}}} -{"type":"assistant/chunk","seq":462,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":463,"time":1783352187548,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":464,"time":1783352187577,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" miss"}}} -{"type":"assistant/chunk","seq":465,"time":1783352187605,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"p"}}} -{"type":"assistant/chunk","seq":466,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"elling"}}} -{"type":"assistant/chunk","seq":467,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} -{"type":"assistant/chunk","seq":468,"time":1783352187606,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":469,"time":1783352187634,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":470,"time":1783352187634,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":471,"time":1783352187635,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":472,"time":1783352187635,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":473,"time":1783352187663,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":474,"time":1783352187663,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" looks"}}} -{"type":"assistant/chunk","seq":475,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":476,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":477,"time":1783352187692,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} -{"type":"assistant/chunk","seq":478,"time":1783352187721,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":479,"time":1783352187722,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":480,"time":1783352187751,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":481,"time":1783352187751,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":482,"time":1783352187782,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":483,"time":1783352187782,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"\")"}}} -{"type":"assistant/chunk","seq":484,"time":1783352187783,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" could"}}} -{"type":"assistant/chunk","seq":485,"time":1783352187813,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} -{"type":"assistant/chunk","seq":486,"time":1783352187818,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" triggering"}}} -{"type":"assistant/chunk","seq":487,"time":1783352187818,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" some"}}} -{"type":"assistant/chunk","seq":488,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" content"}}} -{"type":"assistant/chunk","seq":489,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" filter"}}} -{"type":"assistant/chunk","seq":490,"time":1783352187839,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":491,"time":1783352187840,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":492,"time":1783352187868,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":493,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":494,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":495,"time":1783352187869,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" completely"}}} -{"type":"assistant/chunk","seq":496,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" different"}}} -{"type":"assistant/chunk","seq":497,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} -{"type":"assistant/chunk","seq":498,"time":1783352187897,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":499,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} -{"type":"assistant/chunk","seq":500,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" if"}}} -{"type":"assistant/chunk","seq":501,"time":1783352187926,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":502,"time":1783352187955,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":503,"time":1783352187983,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" works"}}} -{"type":"assistant/chunk","seq":504,"time":1783352188012,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} -{"type":"assistant/chunk","seq":505,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":" all"}}} -{"type":"assistant/chunk","seq":506,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":507,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":508,"time":1783352188041,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":"Let"}}} -{"type":"assistant/chunk","seq":509,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" me"}}} -{"type":"assistant/chunk","seq":510,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" try"}}} -{"type":"assistant/chunk","seq":511,"time":1783352188070,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":512,"time":1783352188071,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" different"}}} -{"type":"assistant/chunk","seq":513,"time":1783352188098,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" word"}}} -{"type":"assistant/chunk","seq":514,"time":1783352188127,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":515,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" verify"}}} -{"type":"assistant/chunk","seq":516,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":517,"time":1783352188128,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":518,"time":1783352188157,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":" works"}}} -{"type":"assistant/chunk","seq":519,"time":1783352188157,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":520,"time":1783352188213,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":521,"time":1783352188214,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":522,"time":1783352188242,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":523,"time":1783352188243,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":524,"time":1783352188243,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":525,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":526,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":527,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":528,"time":1783352188272,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"E"}}} -{"type":"assistant/chunk","seq":529,"time":1783352188299,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"cho"}}} -{"type":"assistant/chunk","seq":530,"time":1783352188300,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" test"}}} -{"type":"assistant/chunk","seq":531,"time":1783352188328,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" message"}}} -{"type":"assistant/chunk","seq":532,"time":1783352188360,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":533,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":534,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":535,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":536,"time":1783352188390,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":537,"time":1783352188391,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":538,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":539,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":540,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":" \\\""}}} -{"type":"assistant/chunk","seq":541,"time":1783352188420,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"TEST"}}} -{"type":"assistant/chunk","seq":542,"time":1783352188463,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\\\""}}} -{"type":"assistant/chunk","seq":543,"time":1783352188463,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":544,"time":1783352188477,"data":{"turn":1,"step":7,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":545,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The policy is blocking the output \"HELLO\" - it seems like the word \"HELLO\" (which might be a misspelling of \"HELLO\" that looks like \"HELL\" with \"O\") could be triggering some content filter. Let me try a completely different word to see if the tool works at all."}}}} -{"type":"assistant/chunk","seq":546,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Let me try a different word to verify the tool works:"}}}} -{"type":"assistant/chunk","seq":547,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}}}} -{"type":"assistant/chunk","seq":548,"time":1783352188511,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":287,"outputTokens":146,"cacheReadTokens":3328,"reasoningTokens":70}}}} -{"type":"assistant/chunk","seq":549,"time":1783352188512,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":550,"time":1783352188512,"data":{"turn":1,"step":7,"content":[{"type":"reasoning","text":"The policy is blocking the output \"HELLO\" - it seems like the word \"HELLO\" (which might be a misspelling of \"HELLO\" that looks like \"HELL\" with \"O\") could be triggering some content filter. Let me try a completely different word to see if the tool works at all."},{"type":"text","text":"Let me try a different word to verify the tool works:"},{"type":"tool-call","id":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":287,"outputTokens":146,"cacheReadTokens":3328,"reasoningTokens":70}},"sourceEventSeqs":[436,437,438,439,440,441,442,443,444,445,446,447,448,449,450,451,452,453,454,455,456,457,458,459,460,461,462,463,464,465,466,467,468,469,470,471,472,473,474,475,476,477,478,479,480,481,482,483,484,485,486,487,488,489,490,491,492,493,494,495,496,497,498,499,500,501,502,503,504,505,506,507,508,509,510,511,512,513,514,515,516,517,518,519,520,521,522,523,524,525,526,527,528,529,530,531,532,533,534,535,536,537,538,539,540,541,542,543,544,545,546,547,548,549],"surfaceOp":"append"} -{"type":"tool/call","seq":551,"time":1783352188512,"data":{"turn":1,"step":7,"callId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","name":"bash","arguments":"{\"description\": \"Echo test message\", \"command\": \"echo \\\"TEST\\\"\"}"}} -{"type":"hook/invoked","seq":552,"time":1783352188524,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:7","matcher":"bash"}} -{"type":"hook/result","seq":553,"time":1783352188532,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:7","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.573905000001105}} -{"type":"tool/result","seq":554,"time":1783352188532,"data":{"turn":1,"step":7,"callId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[551],"surfaceOp":"append"} -{"type":"step/end","seq":555,"time":1783352188532,"data":{"turn":1,"step":7}} -{"type":"step/start","seq":556,"time":1783352188533,"data":{"turn":1,"step":8}} -{"type":"assistant/chunk","seq":557,"time":1783352189213,"data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":558,"time":1783352189214,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"Even"}}} -{"type":"assistant/chunk","seq":559,"time":1783352189362,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":560,"time":1783352189385,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"TEST"}}} -{"type":"assistant/chunk","seq":561,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":562,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":563,"time":1783352189386,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":564,"time":1783352189416,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":565,"time":1783352189416,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":566,"time":1783352189417,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":567,"time":1783352189417,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":568,"time":1783352189443,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":569,"time":1783352189443,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" simpler"}}} -{"type":"assistant/chunk","seq":570,"time":1783352189473,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":571,"time":1783352189474,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":572,"time":1783352189502,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":573,"time":1783352189502,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"p"}}} -{"type":"assistant/chunk","seq":574,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"wd"}}} -{"type":"assistant/chunk","seq":575,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"`"}}} -{"type":"assistant/chunk","seq":576,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" or"}}} -{"type":"assistant/chunk","seq":577,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} -{"type":"assistant/chunk","seq":578,"time":1783352189529,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"ls"}}} -{"type":"assistant/chunk","seq":579,"time":1783352189559,"data":{"turn":1,"step":8,"chunk":{"type":"reasoning-delta","index":0,"text":"`."}}} -{"type":"assistant/chunk","seq":580,"time":1783352189618,"data":{"turn":1,"step":8,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":581,"time":1783352189618,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":582,"time":1783352189645,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":583,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":584,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":585,"time":1783352189646,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":586,"time":1783352189673,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":587,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":588,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":589,"time":1783352189674,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":" working"}}} -{"type":"assistant/chunk","seq":590,"time":1783352189705,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":" directory"}}} -{"type":"assistant/chunk","seq":591,"time":1783352189705,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":592,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":593,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":594,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":595,"time":1783352189734,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":596,"time":1783352189763,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":597,"time":1783352189763,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":598,"time":1783352189764,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"p"}}} -{"type":"assistant/chunk","seq":599,"time":1783352189764,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"wd"}}} -{"type":"assistant/chunk","seq":600,"time":1783352189793,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":601,"time":1783352189793,"data":{"turn":1,"step":8,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":602,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Even \"TEST\" is rejected. Let me try a simpler command like `pwd` or `ls`."}}}} -{"type":"assistant/chunk","seq":603,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}}}} -{"type":"assistant/chunk","seq":604,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"usage","usage":{"inputTokens":201,"outputTokens":84,"cacheReadTokens":3584,"reasoningTokens":22}}}} -{"type":"assistant/chunk","seq":605,"time":1783352189858,"data":{"turn":1,"step":8,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":606,"time":1783352189859,"data":{"turn":1,"step":8,"content":[{"type":"reasoning","text":"Even \"TEST\" is rejected. Let me try a simpler command like `pwd` or `ls`."},{"type":"tool-call","id":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":201,"outputTokens":84,"cacheReadTokens":3584,"reasoningTokens":22}},"sourceEventSeqs":[557,558,559,560,561,562,563,564,565,566,567,568,569,570,571,572,573,574,575,576,577,578,579,580,581,582,583,584,585,586,587,588,589,590,591,592,593,594,595,596,597,598,599,600,601,602,603,604,605],"surfaceOp":"append"} -{"type":"tool/call","seq":607,"time":1783352189859,"data":{"turn":1,"step":8,"callId":"call_00_S2XENjVanaHtBn3RZfhO7965","name":"bash","arguments":"{\"description\": \"Print working directory\", \"command\": \"pwd\"}"}} -{"type":"hook/invoked","seq":608,"time":1783352189876,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:8","matcher":"bash"}} -{"type":"hook/result","seq":609,"time":1783352189883,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:8","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.748225999999704}} -{"type":"tool/result","seq":610,"time":1783352189884,"data":{"turn":1,"step":8,"callId":"call_00_S2XENjVanaHtBn3RZfhO7965","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[607],"surfaceOp":"append"} -{"type":"step/end","seq":611,"time":1783352189884,"data":{"turn":1,"step":8}} -{"type":"step/start","seq":612,"time":1783352189884,"data":{"turn":1,"step":9}} -{"type":"assistant/chunk","seq":613,"time":1783352190934,"data":{"turn":1,"step":9,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":614,"time":1783352190935,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"All"}}} -{"type":"assistant/chunk","seq":615,"time":1783352191025,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":616,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" commands"}}} -{"type":"assistant/chunk","seq":617,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" are"}}} -{"type":"assistant/chunk","seq":618,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" being"}}} -{"type":"assistant/chunk","seq":619,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":620,"time":1783352191054,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":621,"time":1783352191055,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} -{"type":"assistant/chunk","seq":622,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" seems"}}} -{"type":"assistant/chunk","seq":623,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" like"}}} -{"type":"assistant/chunk","seq":624,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} -{"type":"assistant/chunk","seq":625,"time":1783352191083,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" system"}}} -{"type":"assistant/chunk","seq":626,"time":1783352191111,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"-level"}}} -{"type":"assistant/chunk","seq":627,"time":1783352191112,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":628,"time":1783352191112,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" issue"}}} -{"type":"assistant/chunk","seq":629,"time":1783352191141,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" where"}}} -{"type":"assistant/chunk","seq":630,"time":1783352191169,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":631,"time":1783352191170,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":632,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":633,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":634,"time":1783352191202,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" blocked"}}} -{"type":"assistant/chunk","seq":635,"time":1783352191230,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" entirely"}}} -{"type":"assistant/chunk","seq":636,"time":1783352191231,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":637,"time":1783352191231,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":638,"time":1783352191260,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":639,"time":1783352191260,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":640,"time":1783352191288,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} -{"type":"assistant/chunk","seq":641,"time":1783352191289,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":642,"time":1783352191289,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":643,"time":1783352191318,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":644,"time":1783352191318,"data":{"turn":1,"step":9,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":645,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":646,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"I"}}} -{"type":"assistant/chunk","seq":647,"time":1783352191319,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"'m"}}} -{"type":"assistant/chunk","seq":648,"time":1783352191346,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" unable"}}} -{"type":"assistant/chunk","seq":649,"time":1783352191347,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":650,"time":1783352191347,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" run"}}} -{"type":"assistant/chunk","seq":651,"time":1783352191375,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":652,"time":1783352191376,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":653,"time":1783352191376,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} -{"type":"assistant/chunk","seq":654,"time":1783352191404,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" HE"}}} -{"type":"assistant/chunk","seq":655,"time":1783352191404,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} -{"type":"assistant/chunk","seq":656,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"O"}}} -{"type":"assistant/chunk","seq":657,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":658,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" command"}}} -{"type":"assistant/chunk","seq":659,"time":1783352191405,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" via"}}} -{"type":"assistant/chunk","seq":660,"time":1783352191433,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":661,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":662,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":663,"time":1783352191434,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":664,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" every"}}} -{"type":"assistant/chunk","seq":665,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" invocation"}}} -{"type":"assistant/chunk","seq":666,"time":1783352191462,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":667,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" being"}}} -{"type":"assistant/chunk","seq":668,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":669,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":670,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":671,"time":1783352191491,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":672,"time":1783352191525,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" with"}}} -{"type":"assistant/chunk","seq":673,"time":1783352191525,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":674,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" message"}}} -{"type":"assistant/chunk","seq":675,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" \""}}} -{"type":"assistant/chunk","seq":676,"time":1783352191550,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} -{"type":"assistant/chunk","seq":677,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":678,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":679,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":680,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":681,"time":1783352191578,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":682,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" rer"}}} -{"type":"assistant/chunk","seq":683,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"un"}}} -{"type":"assistant/chunk","seq":684,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" with"}}} -{"type":"assistant/chunk","seq":685,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":686,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" summary"}}} -{"type":"assistant/chunk","seq":687,"time":1783352191607,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":688,"time":1783352191635,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"\"."}}} -{"type":"assistant/chunk","seq":689,"time":1783352191636,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" This"}}} -{"type":"assistant/chunk","seq":690,"time":1783352191636,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" appears"}}} -{"type":"assistant/chunk","seq":691,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":692,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" be"}}} -{"type":"assistant/chunk","seq":693,"time":1783352191665,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" a"}}} -{"type":"assistant/chunk","seq":694,"time":1783352191666,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" systemic"}}} -{"type":"assistant/chunk","seq":695,"time":1783352191666,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":696,"time":1783352191692,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" restriction"}}} -{"type":"assistant/chunk","seq":697,"time":1783352191693,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" on"}}} -{"type":"assistant/chunk","seq":698,"time":1783352191693,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":699,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":700,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":701,"time":1783352191722,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" in"}}} -{"type":"assistant/chunk","seq":702,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" this"}}} -{"type":"assistant/chunk","seq":703,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" environment"}}} -{"type":"assistant/chunk","seq":704,"time":1783352191751,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":","}}} -{"type":"assistant/chunk","seq":705,"time":1783352191783,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" as"}}} -{"type":"assistant/chunk","seq":706,"time":1783352191811,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" even"}}} -{"type":"assistant/chunk","seq":707,"time":1783352191841,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" simple"}}} -{"type":"assistant/chunk","seq":708,"time":1783352191842,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" commands"}}} -{"type":"assistant/chunk","seq":709,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" like"}}} -{"type":"assistant/chunk","seq":710,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":711,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"p"}}} -{"type":"assistant/chunk","seq":712,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"wd"}}} -{"type":"assistant/chunk","seq":713,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":714,"time":1783352191871,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":715,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":716,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"echo"}}} -{"type":"assistant/chunk","seq":717,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" \""}}} -{"type":"assistant/chunk","seq":718,"time":1783352191900,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"TEST"}}} -{"type":"assistant/chunk","seq":719,"time":1783352191901,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":720,"time":1783352191927,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":721,"time":1783352191928,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" are"}}} -{"type":"assistant/chunk","seq":722,"time":1783352191928,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} -{"type":"assistant/chunk","seq":723,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":724,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"I"}}} -{"type":"assistant/chunk","seq":725,"time":1783352191986,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" cannot"}}} -{"type":"assistant/chunk","seq":726,"time":1783352192031,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" produce"}}} -{"type":"assistant/chunk","seq":727,"time":1783352192043,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":728,"time":1783352192044,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":729,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":730,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" you"}}} -{"type":"assistant/chunk","seq":731,"time":1783352192072,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" asked"}}} -{"type":"assistant/chunk","seq":732,"time":1783352192115,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" for"}}} -{"type":"assistant/chunk","seq":733,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" because"}}} -{"type":"assistant/chunk","seq":734,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":735,"time":1783352192116,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" bash"}}} -{"type":"assistant/chunk","seq":736,"time":1783352192133,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":737,"time":1783352192133,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":738,"time":1783352192134,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":739,"time":1783352192158,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" currently"}}} -{"type":"assistant/chunk","seq":740,"time":1783352192159,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" available"}}} -{"type":"assistant/chunk","seq":741,"time":1783352192186,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" for"}}} -{"type":"assistant/chunk","seq":742,"time":1783352192215,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":" use"}}} -{"type":"assistant/chunk","seq":743,"time":1783352192245,"data":{"turn":1,"step":9,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":744,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"All bash commands are being rejected. This seems like a system-level policy issue where the bash tool is blocked entirely. Let me report this to the user."}}}} -{"type":"assistant/chunk","seq":745,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"I'm unable to run the `echo HELLO` command via the bash tool — every invocation is being rejected by the policy with the message \"tool output rejected by policy: rerun with a summary instead\". This appears to be a systemic policy restriction on the bash tool in this environment, as even simple commands like `pwd` and `echo \"TEST\"` are blocked.\n\nI cannot produce the tool result you asked for because the bash tool is not currently available for use."}}}} -{"type":"assistant/chunk","seq":746,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"usage","usage":{"inputTokens":181,"outputTokens":130,"cacheReadTokens":3712,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":747,"time":1783352192246,"data":{"turn":1,"step":9,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":748,"time":1783352192247,"data":{"turn":1,"step":9,"content":[{"type":"reasoning","text":"All bash commands are being rejected. This seems like a system-level policy issue where the bash tool is blocked entirely. Let me report this to the user."},{"type":"text","text":"I'm unable to run the `echo HELLO` command via the bash tool — every invocation is being rejected by the policy with the message \"tool output rejected by policy: rerun with a summary instead\". This appears to be a systemic policy restriction on the bash tool in this environment, as even simple commands like `pwd` and `echo \"TEST\"` are blocked.\n\nI cannot produce the tool result you asked for because the bash tool is not currently available for use."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":181,"outputTokens":130,"cacheReadTokens":3712,"reasoningTokens":31}},"sourceEventSeqs":[613,614,615,616,617,618,619,620,621,622,623,624,625,626,627,628,629,630,631,632,633,634,635,636,637,638,639,640,641,642,643,644,645,646,647,648,649,650,651,652,653,654,655,656,657,658,659,660,661,662,663,664,665,666,667,668,669,670,671,672,673,674,675,676,677,678,679,680,681,682,683,684,685,686,687,688,689,690,691,692,693,694,695,696,697,698,699,700,701,702,703,704,705,706,707,708,709,710,711,712,713,714,715,716,717,718,719,720,721,722,723,724,725,726,727,728,729,730,731,732,733,734,735,736,737,738,739,740,741,742,743,744,745,746,747],"surfaceOp":"append"} -{"type":"step/end","seq":749,"time":1783352192247,"data":{"turn":1,"step":9}} -{"type":"turn/end","seq":750,"time":1783352192247,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"669e8682-49fc-4dff-9bc7-6280e283cbe4","createdAt":1783962504097,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-WxJGUY"} +{"type":"turn/start","seq":0,"time":1783962504115,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783962504117,"data":{"content":[{"type":"text","text":"Call the bash tool to run exactly: echo HELLO. If the first tool result is rejected, retry that command once. Quote the final tool result verbatim and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783962504152,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783962504152,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783962505202,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783962505202,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783962505340,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783962505372,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783962505373,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783962505466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":15,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":16,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":17,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":18,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":19,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":20,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":21,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":22,"time":1783962505467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" If"}}} +{"type":"assistant/chunk","seq":24,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":25,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}} +{"type":"assistant/chunk","seq":26,"time":1783962505538,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":27,"time":1783962505539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":28,"time":1783962505539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":29,"time":1783962505545,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":30,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":31,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":32,"time":1783962505546,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":33,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":34,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":35,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":36,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":37,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":38,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":39,"time":1783962505658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":40,"time":1783962505660,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":41,"time":1783962505661,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":42,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":43,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783962505688,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":45,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":47,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783962505717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":49,"time":1783962505747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":50,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":51,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":52,"time":1783962505749,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1783962505774,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":54,"time":1783962505774,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1783962505804,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":56,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":57,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":58,"time":1783962505805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1783962505834,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":60,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":61,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":62,"time":1783962505866,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":63,"time":1783962505867,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":64,"time":1783962505867,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":65,"time":1783962505889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":66,"time":1783962505890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":67,"time":1783962505990,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":68,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}}} +{"type":"assistant/chunk","seq":69,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":70,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":71,"time":1783962505991,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":72,"time":1783962505993,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."},{"type":"tool-call","id":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3263,"outputTokens":103,"cacheReadTokens":0,"reasoningTokens":35}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71],"surfaceOp":"append"} +{"type":"tool/call","seq":73,"time":1783962505993,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":74,"time":1783962506001,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":75,"time":1783962506011,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: retry once","durationMs":9.922291999999743}} +{"type":"tool/result","seq":76,"time":1783962506011,"data":{"turn":1,"step":1,"callId":"call_00_VAByyMjsct4c7P6k1ysX9256","content":[{"type":"text","text":"tool output rejected by policy: retry once"}],"isError":true},"sourceEventSeqs":[73],"surfaceOp":"append"} +{"type":"step/end","seq":77,"time":1783962506012,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":78,"time":1783962506012,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":79,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":80,"time":1783962507038,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":81,"time":1783962507131,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":82,"time":1783962507232,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":83,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":84,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":85,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":86,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":87,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":88,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":89,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":90,"time":1783962507233,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" said"}}} +{"type":"assistant/chunk","seq":91,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":92,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":93,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":94,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":95,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":96,"time":1783962507253,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":97,"time":1783962507307,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":98,"time":1783962507307,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ret"}}} +{"type":"assistant/chunk","seq":99,"time":1783962507308,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ry"}}} +{"type":"assistant/chunk","seq":100,"time":1783962507308,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":101,"time":1783962507374,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":102,"time":1783962507374,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":103,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":104,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":105,"time":1783962507397,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":106,"time":1783962507398,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":107,"time":1783962507398,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":108,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":110,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":111,"time":1783962507426,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":112,"time":1783962507455,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":113,"time":1783962507455,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1783962507563,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":115,"time":1783962507563,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":117,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":119,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":120,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":121,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":122,"time":1783962507564,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":123,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":124,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":125,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":126,"time":1783962507565,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":127,"time":1783962507573,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":128,"time":1783962507573,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":129,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}}} +{"type":"assistant/chunk","seq":130,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":131,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":132,"time":1783962507631,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":133,"time":1783962507632,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The first call was rejected by policy. The user said to retry once. Let me retry."},{"type":"tool-call","id":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":59,"outputTokens":89,"cacheReadTokens":3328,"reasoningTokens":21}},"sourceEventSeqs":[79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"} +{"type":"tool/call","seq":134,"time":1783962507632,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":135,"time":1783962507638,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} +{"type":"hook/result","seq":136,"time":1783962507659,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"pass","exitCode":0,"durationMs":20.260417000000416}} +{"type":"tool/result","seq":137,"time":1783962507659,"data":{"turn":1,"step":2,"callId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"} +{"type":"step/end","seq":138,"time":1783962507660,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":139,"time":1783962507660,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":140,"time":1783962508621,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":141,"time":1783962508621,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":142,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":143,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" attempt"}}} +{"type":"assistant/chunk","seq":144,"time":1783962508803,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" succeeded"}}} +{"type":"assistant/chunk","seq":145,"time":1783962508804,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":146,"time":1783962508811,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":147,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}} +{"type":"assistant/chunk","seq":148,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":149,"time":1783962508812,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":150,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":151,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HE"}}} +{"type":"assistant/chunk","seq":152,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":153,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":154,"time":1783962508839,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":155,"time":1783962508873,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":156,"time":1783962508873,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"The"}}} +{"type":"assistant/chunk","seq":157,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" final"}}} +{"type":"assistant/chunk","seq":158,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} +{"type":"assistant/chunk","seq":159,"time":1783962508901,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" result"}}} +{"type":"assistant/chunk","seq":160,"time":1783962508902,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} +{"type":"assistant/chunk","seq":161,"time":1783962508930,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} +{"type":"assistant/chunk","seq":162,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":163,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":164,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} +{"type":"assistant/chunk","seq":165,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} +{"type":"assistant/chunk","seq":166,"time":1783962508931,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"O"}}} +{"type":"assistant/chunk","seq":167,"time":1783962508983,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":168,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":169,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."}}}} +{"type":"assistant/chunk","seq":170,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} +{"type":"assistant/chunk","seq":171,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":172,"time":1783962508984,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":173,"time":1783962508984,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The second attempt succeeded. The final result is \"HELLO\"."},{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":36,"outputTokens":28,"cacheReadTokens":3456,"reasoningTokens":14}},"sourceEventSeqs":[140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172],"surfaceOp":"append"} +{"type":"step/end","seq":174,"time":1783962508984,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":175,"time":1783962508985,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index 90ab26d54e..af42092168 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,461 +1,90 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" If"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" final"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_qcIzLImnOm5qiKOBJUqY5047","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_qcIzLImnOm5qiKOBJUqY5047","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: retry once\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" again"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summarizes"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" we"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'re"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" doing"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO command"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_nzhKxYvCYj8OqI2tjOKb8506","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" slightly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_VumDDhhB4n5507EUXq650912","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_VumDDhhB4n5507EUXq650912","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" my"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempts"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" very"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" short"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"echo HELLO"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_i5NCqPa2LGAy1wk8uJQR2369","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"H"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mm"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" keeps"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejecting"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" without"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" description"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" field"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" just"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_0IuOIk6iuG6ZesROSyAM3669","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_0IuOIk6iuG6ZesROSyAM3669","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" consistently"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" says"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" would"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" maybe"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" approach"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" satisfy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Wait"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" explicitly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" said"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" exactly"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" you"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" got"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quotes"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ret"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ry"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_mGqWmySh60rWKNcyVBFk2747","title":"echo \"HELLO\"","kind":"execute","status":"in_progress","rawInput":"echo \"HELLO\"","content":[{"type":"content","content":{"type":"text","text":"Echo the string HELLO"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_mGqWmySh60rWKNcyVBFk2747","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nHELLO\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" succeeded"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" final"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocking"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" -"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" ("}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"which"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" might"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" miss"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"elling"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" looks"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\")"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" could"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" triggering"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" some"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" content"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" filter"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" completely"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" see"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" if"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" works"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" all"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" different"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" word"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verify"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" works"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","title":"echo \"TEST\"","kind":"execute","status":"in_progress","rawInput":"echo \"TEST\"","content":[{"type":"content","content":{"type":"text","text":"Echo test message"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Y371DzUR0Pz6IrkO8qgB9050","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Even"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"TEST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simpler"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"wd"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ls"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S2XENjVanaHtBn3RZfhO7965","title":"pwd","kind":"execute","status":"in_progress","rawInput":"pwd","content":[{"type":"content","content":{"type":"text","text":"Print working directory"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S2XENjVanaHtBn3RZfhO7965","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by policy: rerun with a summary instead\n```"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"All"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" commands"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" are"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" seems"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" system"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"-level"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" issue"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" where"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" entirely"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"'m"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" unable"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" via"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" every"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" invocation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" being"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" message"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rer"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"un"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summary"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" appears"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" be"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" a"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" systemic"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" restriction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" on"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" environment"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" even"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" simple"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" commands"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" like"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"p"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"wd"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"echo"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TEST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" are"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" produce"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" final"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" you"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" currently"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" available"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" for"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" use"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json index f5c4fe5f3b..fd8bcf321b 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/hooks.json @@ -4,7 +4,7 @@ { "matcher": "bash", "hooks": [ - { "type": "command", "command": "echo 'tool output rejected by policy: rerun with a summary instead' >&2; exit 2" } + { "type": "command", "command": "sh posttool-once.sh" } ] } ] diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh new file mode 100644 index 0000000000..2acc98bb58 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh @@ -0,0 +1,7 @@ +#!/bin/sh +if test -e .posttool-blocked; then + exit 0 +fi +: > .posttool-blocked +printf '%s\n' 'tool output rejected by policy: retry once' >&2 +exit 2 diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index e60a33bcd5..bed3d3a03a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 3da0d9171e..dcd496f61c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"281bce21-7aee-4642-88a9-56917e50829c","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"281bce21-7aee-4642-88a9-56917e50829c","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"9ba4bf87-d8b5-448f-94e6-6a8a0e43a8ad","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"9ba4bf87-d8b5-448f-94e6-6a8a0e43a8ad","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index a748546e51..48b5df1ac2 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index 60ae1f14e4..74f4b9ea10 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 765dd87f7f..29023a8d45 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index e609fc0d70..15bf48cb81 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index c7e8beeab3..39cf7df191 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json index 3d44990f9b..e2ddb4cc41 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." } + { "op": "prompt", "text": "Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool." } ] } diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 4272fe6a94..6d08c3ee7d 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,222 +1,118 @@ -{"type":"session","version":0,"id":"7a5183c0-ec3a-46a8-a382-475eaa0c205b","createdAt":1783352220743,"cwd":"/tmp/acp-snap-cwd-vGnYqn"} -{"type":"turn/start","seq":0,"time":1783352220747,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352220748,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352220749,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352220750,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352221651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":13,"time":1783352221709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1783352221710,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":15,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":16,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":17,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":18,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":20,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":21,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":22,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":23,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":24,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":25,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":26,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":27,"time":1783352221794,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":28,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":29,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":30,"time":1783352221884,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":31,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":32,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":33,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":34,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":35,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":37,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":39,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":40,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":41,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":42,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":44,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":50,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":51,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":52,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":53,"time":1783352222058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":54,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":55,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783352222088,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":57,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":60,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1783352222124,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1783352222124,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":63,"time":1783352222138,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":64,"time":1783352222148,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":9.571565000000192}} -{"type":"tool/result","seq":65,"time":1783352222148,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1783352222149,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":67,"time":1783352222149,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":68,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":69,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":70,"time":1783352223301,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1783352223315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":72,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":73,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":74,"time":1783352223343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":75,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":76,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":77,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":78,"time":1783352223372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":79,"time":1783352223406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":80,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":81,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":82,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":83,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sum"}}} -{"type":"assistant/chunk","seq":84,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mar"}}} -{"type":"assistant/chunk","seq":85,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ize"}}} -{"type":"assistant/chunk","seq":86,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":87,"time":1783352223458,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":88,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} -{"type":"assistant/chunk","seq":89,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" means"}}} -{"type":"assistant/chunk","seq":90,"time":1783352223488,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":91,"time":1783352223519,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cannot"}}} -{"type":"assistant/chunk","seq":92,"time":1783352223520,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" show"}}} -{"type":"assistant/chunk","seq":93,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":94,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" raw"}}} -{"type":"assistant/chunk","seq":95,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":96,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":97,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":98,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":99,"time":1783352223605,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":100,"time":1783352223640,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":101,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":102,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":103,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":104,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":105,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":106,"time":1783352223663,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":107,"time":1783352223664,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} -{"type":"assistant/chunk","seq":108,"time":1783352223691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":109,"time":1783352223692,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":110,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":111,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happened"}}} -{"type":"assistant/chunk","seq":112,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":113,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} -{"type":"assistant/chunk","seq":114,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":115,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":116,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":117,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":118,"time":1783352223778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":119,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":120,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":121,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":122,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":123,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":124,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":125,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":126,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":127,"time":1783352223865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":128,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":129,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":130,"time":1783352223896,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":131,"time":1783352223897,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} -{"type":"assistant/chunk","seq":132,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":133,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":134,"time":1783352223952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":135,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":136,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"tool"}}} -{"type":"assistant/chunk","seq":137,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":138,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":139,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":140,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":141,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":142,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":143,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":144,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summarize"}}} -{"type":"assistant/chunk","seq":145,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":146,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":147,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} -{"type":"assistant/chunk","seq":148,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":149,"time":1783352224018,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":150,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":151,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":152,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":153,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":154,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":155,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":156,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":157,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":158,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":159,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":160,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":161,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":162,"time":1783352224129,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":163,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":164,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} -{"type":"assistant/chunk","seq":165,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":166,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} -{"type":"assistant/chunk","seq":167,"time":1783352224187,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":168,"time":1783352224215,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":169,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":170,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":171,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":172,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":173,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":174,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":175,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":176,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`\n\n"}}} -{"type":"assistant/chunk","seq":177,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"This"}}} -{"type":"assistant/chunk","seq":178,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":179,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":180,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":181,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":182,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":183,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" from"}}} -{"type":"assistant/chunk","seq":184,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":185,"time":1783352224331,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":186,"time":1783352224360,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":187,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":188,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} -{"type":"assistant/chunk","seq":189,"time":1783352224392,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":190,"time":1783352224418,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":191,"time":1783352224446,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" expected"}}} -{"type":"assistant/chunk","seq":192,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":193,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":194,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} -{"type":"assistant/chunk","seq":195,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} -{"type":"assistant/chunk","seq":196,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"O"}}} -{"type":"assistant/chunk","seq":197,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":198,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":199,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":200,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" because"}}} -{"type":"assistant/chunk","seq":201,"time":1783352224533,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":202,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":203,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":204,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":205,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} -{"type":"assistant/chunk","seq":206,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":207,"time":1783352224592,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":208,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} -{"type":"assistant/chunk","seq":209,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" me"}}} -{"type":"assistant/chunk","seq":210,"time":1783352224653,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":211,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":212,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":213,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":214,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."}}}} -{"type":"assistant/chunk","seq":215,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}}}} -{"type":"assistant/chunk","seq":216,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}}}} -{"type":"assistant/chunk","seq":217,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":218,"time":1783352224655,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."},{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217],"surfaceOp":"append"} -{"type":"step/end","seq":219,"time":1783352224655,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":220,"time":1783352224655,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP"} +{"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783986962240,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783986962240,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":15,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":17,"time":1783986963160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":18,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":19,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":20,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":21,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} +{"type":"assistant/chunk","seq":22,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":23,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":24,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":26,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":27,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":28,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":30,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783986963314,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1783986963315,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":36,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":38,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":40,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":41,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":42,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":43,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783986963428,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":45,"time":1783986963429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":47,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":49,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783986963489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":51,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":52,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":53,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":54,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":55,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":56,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":57,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783986963658,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":59,"time":1783986963659,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} +{"type":"assistant/chunk","seq":60,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":62,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1783986963663,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."},{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1783986963664,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":65,"time":1783986963673,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":66,"time":1783986963677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":4.42941699999983}} +{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":1783986963678,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":69,"time":1783986963679,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":70,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":71,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":72,"time":1783986964809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":73,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":74,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":75,"time":1783986964836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":76,"time":1783986964864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":77,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":78,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":79,"time":1783986964893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":80,"time":1783986964899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":81,"time":1783986964900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":82,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":83,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":84,"time":1783986964955,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":85,"time":1783986964985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":86,"time":1783986965013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} +{"type":"assistant/chunk","seq":87,"time":1783986965014,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":88,"time":1783986965045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":89,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":90,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":91,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"<"}}} +{"type":"assistant/chunk","seq":92,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":93,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_result"}}} +{"type":"assistant/chunk","seq":94,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} +{"type":"assistant/chunk","seq":95,"time":1783986965233,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":96,"time":1783986965234,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":97,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":98,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":99,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":100,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":101,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":102,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":103,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} +{"type":"assistant/chunk","seq":104,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} +{"type":"assistant/chunk","seq":105,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":109,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":110,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} +{"type":"assistant/chunk","seq":111,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}}}} +{"type":"assistant/chunk","seq":112,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":113,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":114,"time":1783986965238,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."},{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} +{"type":"step/end","seq":115,"time":1783986965238,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":116,"time":1783986965238,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index 6257014596..7870b73dc2 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,128 +1,56 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`,"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sum"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mar"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" means"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cannot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" show"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" raw"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happened"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" got"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" got"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"<"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":">"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} @@ -133,42 +61,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" expected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index 55310f6b3a..cece2795f7 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 8eba2d862e..1a0e83d191 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 765dd87f7f..29023a8d45 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index 76140724de..15a91d1af3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index af9959379d..821d648791 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl index 011d06f5d2..291525f825 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -23,7 +23,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ASH"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-pro\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-pro\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 5c38cacf55..9a55a86f02 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json b/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json new file mode 100644 index 0000000000..e5356e4af5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl new file mode 100644 index 0000000000..81503dc4cf --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl new file mode 100644 index 0000000000..8680db3d30 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_b","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt new file mode 100644 index 0000000000..4a58007052 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/a.txt @@ -0,0 +1 @@ +alpha diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt new file mode 100644 index 0000000000..65b2df87f7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/workspace/b.txt @@ -0,0 +1 @@ +beta diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index be9ca10cf5..358e81f076 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -44,7 +44,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index 3a900cfe34..8469933a94 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index 6a3dfcc4c4..a1b4b8c0cb 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index 0574f2e6c4..e2941dd851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index bfc195aa36..e5cc8bfa90 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index 7f9d2c51fa..bd4fb81d4a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 4a07c313e0..2b77e856e6 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index 29528d9984..c717c3182a 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index fb2cd46879..8771e50182 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 6abf7023b5..2c19d8feb9 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 4566071090..03f482bcc6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl index f99eb73eef..a55c6d6e01 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"Read nested/task.txt","kind":"read","status":"in_progress","locations":[{"path":"nested/task.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 1c76f1ac61..4e8db74ba6 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md deleted file mode 100644 index cc71975961..0000000000 --- a/examples/coding-agent/composition.md +++ /dev/null @@ -1,88 +0,0 @@ - - -# Coding Agent App Composition - -The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. - -```mermaid -flowchart LR - cfg["examples/coding-agent
cordis.yml"] - plugin_coding_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_coding_hmr - plugin_coding_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_coding_llm_deepseek - plugin_coding_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_coding_bash - plugin_coding_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_coding_stdio_agent - plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_coding_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_coding_token_meter - plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] - cfg --> plugin_coding_compact_basic - plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_coding_subagent - plugin_coding_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] - cfg --> plugin_coding_subagent_spawn - plugin_coding_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] - cfg --> plugin_coding_subagent_fork - plugin_coding_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_coding_tool_subagent - plugin_coding_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_coding_tool_subagent_fork - plugin_coding_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] - cfg --> plugin_coding_workflow_workerthread - plugin_coding_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_coding_tool_workflow - plugin_coding_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_coding_tool_todo - plugin_coding_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_coding_fs_local - plugin_coding_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_coding_fs_policy - plugin_coding_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_coding_tool_fs - plugin_coding_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_coding_tool_fs_search - plugin_coding_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_coding_timeout_policy - plugin_coding_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_coding_spill_local - plugin_coding_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_coding_spill_policy -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | -| `subagent` | `@deepseek-ai/dsh-subagent` | -| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | -| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | -| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | -| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | -| `spill-local` | `@deepseek-ai/dsh-spill-local` | -| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | - -Source config: [`examples/coding-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 1ddf8b1d3b..5900d6a0f0 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,6 +1,6 @@ # cordis-agent -The self-referential harness demo: the coding-agent spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the coding spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 6a08c379fc..499379482f 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_cordis_stdio_agent plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] + plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 68fe88038b..f12f85b6ca 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import { cordisHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the self-referential cordis tools: a REAL model drives @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -66,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -113,7 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 19868af14c..014ce74f7c 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -1,5 +1,6 @@ import { Context } from 'cordis' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -28,7 +29,7 @@ export async function cordisHarness(): Promise { return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index d6156130db..f397cc633f 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -4,12 +4,12 @@ Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-m ## What it shows -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. - `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. -Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `coding-agent` — the same app, a different backend. +Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `repl-agent` — the same app, a different backend. ## Plugin files diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 9c9f04cb50..15f8e078fb 100644 --- a/examples/echo-agent/composition.md +++ b/examples/echo-agent/composition.md @@ -22,7 +22,7 @@ flowchart LR cfg --> plugin_echo_stdio_agent plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_echo_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] + plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index 429ca118d6..6b3d19839b 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -29,7 +29,8 @@ config: cwd: !!js process.cwd() -# The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. +# The app pre-creates `main` on the mock model and supplies persistence plus +# TTY-selected `dsh-tui`/`dsh-stdio` front doors; readline mode also owns logging. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-demo' config: diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md new file mode 100644 index 0000000000..285263146f --- /dev/null +++ b/examples/headless-agent/README.md @@ -0,0 +1,24 @@ +# headless-agent + +Headless one-shot agent wiring: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows + `todo_write` + JSONL persistence, with [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) as the app front door. + +## Run it + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:headless -- "fix the failing test in this workspace" +pnpm run demo:headless --output-format json -- "summarize the implementation" +pnpm run demo:headless --output-format stream-json -- "run the focused tests" +``` + +Exactly one nonblank positional task is required; quote tasks containing spaces. There is no `-p` flag. `text` prints the last text-bearing assistant message, `json` prints one DSH-native result record, and `stream-json` emits the top-level session's canonical task-turn events before that record. Child sessions surface only through parent tool events and results. + +Each invocation creates and persists a fresh session, runs all model and tool steps in one turn, flushes, disposes, and exits. This is non-interactive automation: there is no prompt, approval, resume, second turn, or stdin context. The configured tools can mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. + +## Advanced and snapshot wiring + +[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the shipped leaf. [`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) replaces only the live LLM with replay. The tests under [`tests/`](tests/) own the keyless real-Loader smoke, key-gated world-verified smoke, and the `stream-json` replay snapshot with its parent and child session fixtures. + +The package-level [CLI contract](../../packages/examples/cli-demo/README.md) documents output records, exit status, cancellation, persistence, and model/token effects. diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml new file mode 100644 index 0000000000..48541a1054 --- /dev/null +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Replay counterpart to advanced.cordis.yml; only the live model is replaced. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./advanced.cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml new file mode 100644 index 0000000000..862a2f769b --- /dev/null +++ b/examples/headless-agent/advanced.cordis.yml @@ -0,0 +1,25 @@ +# Add Code Mode and Cordis tools to the headless spawn/workflow stack. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + tools: + mode: both + persona: | + You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md new file mode 100644 index 0000000000..9533af28d3 --- /dev/null +++ b/examples/headless-agent/composition.md @@ -0,0 +1,70 @@ + + +# Headless Agent App Composition + +The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session. + +```mermaid +flowchart LR + cfg["examples/headless-agent
cordis.yml"] + plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_headless_llm_deepseek + plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_headless_bash + plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] + cfg --> plugin_headless_cli_agent + plugin_headless_cli_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_headless_cli_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_headless_cli_agent --> frontdoor_cli["one-shot driver
format-pure stdout
fresh top-level agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_headless_compact_basic + plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_headless_subagent + plugin_headless_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_headless_subagent_spawn + plugin_headless_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_headless_subagent_fork + plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_headless_tool_subagent + plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_headless_tool_subagent_fork + plugin_headless_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_headless_workflow_workerthread + plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_headless_tool_workflow + plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_headless_tool_todo + plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_headless_fs_local + plugin_headless_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_headless_fs_policy + plugin_headless_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_headless_tool_fs +``` + +| Plugin id | Package / module | +| --- | --- | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | + +Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml new file mode 100644 index 0000000000..7f48c529c4 --- /dev/null +++ b/examples/headless-agent/cordis.yml @@ -0,0 +1,99 @@ +# One-shot coding agent with format-pure stdout. The app bin loads the +# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional +# `DEEPSEEK_BASE_URL` through `!!js`. + +# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed +# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort). +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-pro + - id: deepseek-v4-flash + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +# The app bundle pre-creates one fresh `main` agent per invocation. +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: deepseek + model: deepseek-v4-flash + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + persona: | + You are headless-agent, a coding assistant powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +# Summarize an older range when derived history approaches the context window. +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + contextWindow: 128000 + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 + +# Expose fresh-child `spawn` and completed-prefix `fork` through independent +# in-process backends. +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend. +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +# `todo_write` replaces the logged whole list. +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + +# Policy loads before the model-facing filesystem tools so writes and edits +# require an observed file. Relative paths resolve from the process cwd. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/headless-agent/package.json b/examples/headless-agent/package.json new file mode 100644 index 0000000000..c331af0f05 --- /dev/null +++ b/examples/headless-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "headless-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: one complete headless coding-agent turn" +} diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts new file mode 100644 index 0000000000..5238e67374 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -0,0 +1,37 @@ +import type { Context } from 'cordis' +import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Keyless headless-agent adapter: one real bash call followed by a final answer. */ +class CliMockAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') + if (toolResult === undefined) { + const args = JSON.stringify({ command: 'printf CLI_TOOL_ROUND_TRIP', description: 'Prove the CLI tool round trip.' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: CallId('cli-smoke-call'), name: 'bash', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('cli-smoke-call'), name: 'bash', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 11, outputTokens: 3, cacheReadTokens: 2 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const toolText = toolResult.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + const reply = `CLI tool round trip complete: ${toolText.trim()}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 7, outputTokens: 5, reasoningTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'cli-mock-llm' +export const inject = ['llm'] + +/** Register the keyless `cli-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml new file mode 100644 index 0000000000..91941c108a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/cli.cordis.yml @@ -0,0 +1,19 @@ +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persistenceRoot: './.sessions' + workspaceContext: false + persona: 'Keyless headless-agent smoke.' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts new file mode 100644 index 0000000000..ff27347246 --- /dev/null +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -0,0 +1,140 @@ +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { delimiter, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + normalizeSessionLog, + normalizeStdout, + scrubRequestHeaders, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { describe, expect, it } from 'vitest' + +const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +const scenarioDir = join(snapshotsDir, 'advanced-toolchain') +const sessionFixture = join(scenarioDir, 'session.jsonl') +const streamGolden = join(scenarioDir, 'stream-json.golden.jsonl') +const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const refreshing = process.env.DSH_SNAPSHOT === 'refresh' + +interface JsonObject { + [key: string]: unknown +} + +interface PersistedLog { + readonly content: string + readonly header: JsonObject +} + +function parseJsonl(content: string): JsonObject[] { + return content.split('\n') + .filter(line => line.trim().length > 0) + .map(line => JSON.parse(line) as JsonObject) +} + +function contextFromLogs(contents: readonly string[]): NormalizeContext { + const headers = contents.map(content => parseJsonl(content)[0]) + return { + sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []), + cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0', + } +} + +function normalizeHeadlessStream(rawStdout: string, cwd: string): string { + const records = parseJsonl(rawStdout) + if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records') + const final = records.at(-1) + if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record') + if (records.slice(0, -1).some(record => record.type !== 'session_event')) { + throw new Error('headless snapshot emitted a non-event record before its result') + } + + const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))] + if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`) + const context: NormalizeContext = { sessionIds, cwd } + const events = records.slice(0, -1).map((record) => { + if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) { + throw new Error('headless snapshot emitted an invalid session event') + } + return record.event as JsonObject + }) + const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog( + `${events.map(event => JSON.stringify(event)).join('\n')}\n`, + context, + ))) + const normalizedRecords = records.map((record, index) => index < normalizedEvents.length + ? { ...record, event: normalizedEvents[index] } + : record) + return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) +} + +async function advancedPrompt(): Promise { + const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as { + steps?: { op?: unknown; text?: unknown }[] + } + const prompt = input.steps?.find(step => step.op === 'prompt')?.text + if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step') + return prompt +} + +async function persistedLogs(cwd: string): Promise { + const root = join(cwd, '.sessions') + const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) + return Promise.all(files.map(async (file) => { + const content = await readFile(join(root, file), 'utf8') + return { content, header: parseJsonl(content)[0] ?? {} } + })) +} + +describe('headless stream-json snapshots', () => { + it('replays the advanced toolchain through the one-shot app', async () => { + const prompt = await advancedPrompt() + const expectedSessions = await Promise.all([ + sessionFixture, + join(scenarioDir, 'session.1.jsonl'), + join(scenarioDir, 'session.2.jsonl'), + ].map(file => readFile(file, 'utf8'))) + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'advanced headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-advanced-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: sessionFixture, + DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter), + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(3) + const parents = logs.filter(log => typeof log.header.parentSession !== 'string') + expect(parents).toHaveLength(1) + const parent = parents[0] + if (parent === undefined) throw new Error('headless snapshot did not persist its main session') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + const actualSessions = [parent, ...children] + const actualContext = contextFromLogs(actualSessions.map(log => log.content)) + const expectedContext = contextFromLogs(expectedSessions) + for (const [index, actual] of actualSessions.entries()) { + const expected = expectedSessions[index] + if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`) + expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext))) + } + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamGolden, normalized) + expect(normalized).toBe(await readFile(streamGolden, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts new file mode 100644 index 0000000000..57b8660c03 --- /dev/null +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -0,0 +1,43 @@ +import { readdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +describe('headless-agent keyless smoke', () => { + it('boots the real Loader tree, runs a real bash tool round trip, and persists the turn', async () => { + let persisted = false + const { stdout, stderr } = await runLoaderSmoke({ + label: 'headless-agent', + tempDirPrefix: 'headless-agent-smoke-', + binScript, + configPath, + binArgs: ['--config', configPath, '--output-format', 'stream-json', 'prove the tool path'], + tsconfigPath, + inspect: async (cwd) => { + const files = await readdir(cwd, { recursive: true }) + persisted = files.some(file => file.endsWith('.jsonl')) + }, + }) + const lines = stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) + const result = lines.at(-1) + expect(stderr).toBe('') + expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) + const toolResult = events.find(event => event.type === 'tool/result') + expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') + expect(result).toMatchObject({ + type: 'result', + success: true, + turn: 1, + reason: { kind: 'completed' }, + usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 }, + }) + expect(String(result?.['result'])).toContain('CLI_TOOL_ROUND_TRIP') + expect(persisted).toBe(true) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/headless-agent/tests/real-model.e2e.ts b/examples/headless-agent/tests/real-model.e2e.ts new file mode 100644 index 0000000000..653f5ab624 --- /dev/null +++ b/examples/headless-agent/tests/real-model.e2e.ts @@ -0,0 +1,33 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const hasKey = Boolean(process.env.DEEPSEEK_API_KEY) + +describe.skipIf(!hasKey)('headless-agent with real model', () => { + it('modifies a temporary workspace and verifies the file outside the agent', async () => { + let verified = '' + const { stdout } = await runLoaderSmoke({ + label: 'headless-agent real model', + tempDirPrefix: 'headless-agent-real-', + binScript, + configPath, + binArgs: [ + '--config', + configPath, + 'Read task.txt, replace its complete contents with exactly "value=after" followed by a newline, read it again, and report briefly.', + ], + tsconfigPath, + processTimeoutMs: 120_000, + prepare: cwd => writeFile(join(cwd, 'task.txt'), 'value=before\n'), + inspect: async (cwd) => { verified = await readFile(join(cwd, 'task.txt'), 'utf8') }, + }) + expect(verified).toBe('value=after\n') + expect(stdout.trim().length).toBeGreaterThan(0) + }, 135_000) +}) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json new file mode 100644 index 0000000000..41072a211a --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } + ] +} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl new file mode 100644 index 0000000000..b4e8cd6340 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl new file mode 100644 index 0000000000..d8d2e59e6e --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-headless","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl new file mode 100644 index 0000000000..e3849110ce --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl new file mode 100644 index 0000000000..44577f1fe0 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl @@ -0,0 +1,64 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":19,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":20,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":21,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":31,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":33,"time":0,"data":{"turn":1,"step":3}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":34,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":40,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":41,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":43,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":44,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":50,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":51,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":53,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":54,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":60,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":61,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":62,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":1,"result":"ADVANCED_HEADLESS_OK","reason":{"kind":"completed"},"usage":{"inputTokens":18,"outputTokens":18}} diff --git a/examples/package.json b/examples/package.json index 8d77731a05..76349b2ea4 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", diff --git a/examples/coding-agent/README.md b/examples/repl-agent/README.md similarity index 75% rename from examples/coding-agent/README.md rename to examples/repl-agent/README.md index 9e627324d4..7145873e9a 100644 --- a/examples/coding-agent/README.md +++ b/examples/repl-agent/README.md @@ -1,6 +1,6 @@ -# coding-agent +# repl-agent -The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. The UI is a terminal readline REPL. +The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + workflows + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. ## Run it @@ -11,15 +11,9 @@ The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem t pnpm run demo:repl ``` -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. +Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. -``` -> fix the failing test in /path/to/project -[main turn 1] (reasoning…) - [tool call] bash({"command": "node --test", "workdir": "/path/to/project"}) - [tool result] … [exit code: 1] - … -``` +The REPL renders reasoning, tool calls/results, and the latest todo list as line-oriented output suitable for terminals and pipes. Use `pnpm run demo:tui` for the interactive Markdown/card interface. ### Resuming a prior session @@ -29,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio RESUME_SESSION_ID= pnpm run demo:repl ``` -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. +The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero, while readline reports any dropped queued input and allows piped EOF to finish. Unset it or choose an existing session id. ## Code Mode @@ -48,17 +42,18 @@ and watch the transcript: one `run_code` call, a program looping over tools, and ## What each leaf entry demonstrates -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: +This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the selected terminal channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | +| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | +| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | +| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | | `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | ## End-to-end tests (`pnpm run test:e2e`, key-gated) @@ -69,4 +64,4 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads - `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. - `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event. -These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt → no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay). +These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`. diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/repl-agent/code-mode.cordis.yml similarity index 92% rename from examples/coding-agent/code-mode.cordis.yml rename to examples/repl-agent/code-mode.cordis.yml index 3fec40e0f3..8802b510ba 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/repl-agent/code-mode.cordis.yml @@ -20,8 +20,10 @@ tools: mode: code welcome: 'code-mode agent ready. Give it a multi-tool task.' + ui: + mode: readline persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. You work by writing TypeScript programs for run_code: batch related tool work into one program, loop and branch where it helps, and print diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md new file mode 100644 index 0000000000..af3f810585 --- /dev/null +++ b/examples/repl-agent/composition.md @@ -0,0 +1,88 @@ + + +# REPL Agent App Composition + +The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. + +```mermaid +flowchart LR + cfg["examples/repl-agent
cordis.yml"] + plugin_repl_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_repl_hmr + plugin_repl_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_repl_llm_deepseek + plugin_repl_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_repl_bash + plugin_repl_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] + cfg --> plugin_repl_stdio_agent + plugin_repl_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_repl_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_repl_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_repl_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_repl_token_meter + plugin_repl_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_repl_compact_basic + plugin_repl_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_repl_subagent + plugin_repl_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_repl_subagent_spawn + plugin_repl_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_repl_subagent_fork + plugin_repl_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_repl_tool_subagent + plugin_repl_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_repl_tool_subagent_fork + plugin_repl_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_repl_workflow_workerthread + plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_repl_tool_workflow + plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_repl_tool_todo + plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_repl_fs_local + plugin_repl_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_repl_fs_policy + plugin_repl_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_repl_tool_fs + plugin_repl_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_repl_tool_fs_search + plugin_repl_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_repl_timeout_policy + plugin_repl_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_repl_spill_local + plugin_repl_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_repl_spill_policy +``` + +| Plugin id | Package / module | +| --- | --- | +| `hmr` | `@cordisjs/plugin-hmr` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | + +Source config: [`examples/repl-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/coding-agent/cordis.yml b/examples/repl-agent/cordis.yml similarity index 90% rename from examples/coding-agent/cordis.yml rename to examples/repl-agent/cordis.yml index bc3b542592..4428d5f3a8 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -1,6 +1,6 @@ -# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` -# supplies the agent spine, workspace instructions, generic task controls, -# logging, JSONL persistence, readline UI, and `main` agent. +# Readline coding REPL with swappable DeepSeek and local-bash backends. +# `dsh-stdio-demo` supplies the agent spine, workspace instructions, generic +# task controls, JSONL persistence, the line-oriented front door, and `main`. # HMR remains a leaf because it requires Loader internals; `demo:repl` passes # `--expose-internals`. The app bin loads the gitignored root `.env`; this file # reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. @@ -37,10 +37,12 @@ workspaceContext: maxBytes: 65536 welcome: 'agent REPL ready. Give it a coding task.' + ui: + mode: readline # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. @@ -49,8 +51,8 @@ - id: token-meter name: '@deepseek-ai/dsh-token-meter' -# Summarize an older range when measured history approaches the context window. -# Service-wide policy provides the ordinary threshold and retained-tail defaults. +# Summarize an older range after measured pressure or a canonical provider overflow. +# Service-wide policy provides pressure, retention, and one overflow-retry default. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/examples/coding-agent/package.json b/examples/repl-agent/package.json similarity index 81% rename from examples/coding-agent/package.json rename to examples/repl-agent/package.json index b3594ff597..34c7db6918 100644 --- a/examples/coding-agent/package.json +++ b/examples/repl-agent/package.json @@ -1,5 +1,5 @@ { - "name": "coding-agent-example", + "name": "repl-agent-example", "private": true, "version": "0.0.1", "type": "module", diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts similarity index 100% rename from examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts rename to examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/repl-agent/tests/code-mode.e2e.ts similarity index 93% rename from examples/coding-agent/tests/code-mode.e2e.ts rename to examples/repl-agent/tests/code-mode.e2e.ts index b6b55048e4..c7f5d48562 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/repl-agent/tests/code-mode.e2e.ts @@ -8,8 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -24,7 +25,7 @@ import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ -const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' +const PERSONA = 'You are a coding agent. You work by writing TypeScript programs for run_code: ' + 'batch related tool work into one program and print or return ONLY the findings that matter.' const WORKSPACE_PROBE = 'dragonfruit-8675309' @@ -72,7 +73,7 @@ async function workspaceCodeModeHarness(): Promise { return harness } -function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -87,7 +88,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -136,7 +137,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') ctx = await workspaceCodeModeHarness() const handle = await ctx.agents.create({ - agentId: AgentId('e2e-code-mode-workspace'), sessionId: SessionId('e2e-code-mode-workspace-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, @@ -146,7 +146,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/repl-agent/tests/coding-task.e2e.ts similarity index 94% rename from examples/coding-agent/tests/coding-task.e2e.ts rename to examples/repl-agent/tests/coding-task.e2e.ts index d53688835e..a5f525e5c3 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/repl-agent/tests/coding-task.e2e.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The swebench-style smoke test: a real model fixes a real bug in a temp @@ -54,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/repl-agent/tests/compaction.e2e.ts similarity index 95% rename from examples/coding-agent/tests/compaction.e2e.ts rename to examples/repl-agent/tests/compaction.e2e.ts index 6ba2908ad3..d992fc9efa 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/repl-agent/tests/compaction.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Key-gated smoke for mid-session compaction. It verifies the compact event @@ -46,7 +46,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: join(workdir, '.sessions'), }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/repl-agent/tests/full-loop.e2e.ts similarity index 91% rename from examples/coding-agent/tests/full-loop.e2e.ts rename to examples/repl-agent/tests/full-loop.e2e.ts index 7314b7ab21..db2eec63fc 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/repl-agent/tests/full-loop.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The first place a REAL model meets the REAL bash tool: the cheap canary @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas it('runs a bash command on request and reports its output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts similarity index 94% rename from examples/coding-agent/tests/harness.ts rename to examples/repl-agent/tests/harness.ts index 944d01a0b9..eeba57fc61 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/repl-agent/tests/harness.ts @@ -1,6 +1,7 @@ import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -13,7 +14,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** - * Shared harness for the coding-agent e2e suites: the full plugin stack + * Shared harness for the repl-agent e2e suites: the full plugin stack * with the real DeepSeek adapter and the real bash + todo_write tools. Lives * outside the *.e2e.ts pattern so importing it never re-registers another * file's tests. @@ -71,7 +72,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/repl-agent/tests/keyless-smoke.e2e.ts similarity index 81% rename from examples/coding-agent/tests/keyless-smoke.e2e.ts rename to examples/repl-agent/tests/keyless-smoke.e2e.ts index 831d0daf5c..62eb43f55a 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/repl-agent/tests/keyless-smoke.e2e.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Keyless Loader-path smoke for examples/coding-agent: boot the real example + * Keyless Loader-path smoke for examples/repl-agent: boot the real example * through the stdio-agent bin and its `cordis.yml`, then close stdin without a * prompt and assert the banner. The dummy key satisfies adapter construction; * immediate EOF guarantees there is no model call. @@ -13,11 +13,11 @@ const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/s const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { +describe('repl-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { const { stdout } = await runLoaderSmoke({ - label: 'coding-agent', - tempDirPrefix: 'coding-smoke-', + label: 'repl-agent', + tempDirPrefix: 'repl-smoke-', binScript, configPath, tsconfigPath, diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/repl-agent/tests/resume.e2e.ts similarity index 92% rename from examples/coding-agent/tests/resume.e2e.ts rename to examples/repl-agent/tests/resume.e2e.ts index c9081c659f..01c7d52393 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/repl-agent/tests/resume.e2e.ts @@ -3,8 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -40,10 +38,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = (await ctx.agents.create({ - agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -54,10 +51,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ - agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/repl-agent/tests/todo-write.e2e.ts similarity index 92% rename from examples/coding-agent/tests/todo-write.e2e.ts rename to examples/repl-agent/tests/todo-write.e2e.ts index a16f15528d..daf5c018b1 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/repl-agent/tests/todo-write.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * A REAL model drives the REAL todo_write tool: verify the WORLD (the session @@ -26,7 +26,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a it('appends a todo/write event with the model-produced task list', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md new file mode 100644 index 0000000000..b4074881fb --- /dev/null +++ b/examples/tui-agent/README.md @@ -0,0 +1,23 @@ +# tui-agent + +The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. + +## Run it + +```sh +pnpm run demo:tui +``` + +The command needs `DEEPSEEK_API_KEY` in the environment or the gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. + +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. + +Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. + +## Composition + +[`cordis.yml`](cordis.yml) includes the readline repl-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the repl-agent Code Mode overlay. + +## Snapshot tests + +`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable terminal cell/style goldens. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot RFC](../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml new file mode 100644 index 0000000000..75d2cea38a --- /dev/null +++ b/examples/tui-agent/code-mode.cordis.yml @@ -0,0 +1,30 @@ +# Code Mode keeps the TUI front door while reusing the repl-agent overlay's +# worker runtime and one-tool registry composition. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../repl-agent/code-mode.cordis.yml + patches: + - id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + tools: + mode: code + welcome: 'TUI Code Mode ready. Give it a multi-tool task.' + ui: + mode: tui + tui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are a coding agent powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md new file mode 100644 index 0000000000..94515c32c4 --- /dev/null +++ b/examples/tui-agent/composition.md @@ -0,0 +1,28 @@ + + +# TUI Agent App Composition + +The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. + +```mermaid +flowchart LR + cfg["examples/tui-agent
cordis.yml"] + plugin_tui_base["base
@deepseek-ai/dsh-stdio-demo"] + cfg --> plugin_tui_base + plugin_tui_base --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_tui_base --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_tui_base --> frontdoor_stdio["@deepseek-ai/dsh-tui
pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] +``` + +| Plugin id | Package / module | +| --- | --- | +| `base` | `@deepseek-ai/dsh-stdio-demo` | + +Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml new file mode 100644 index 0000000000..515274b2a8 --- /dev/null +++ b/examples/tui-agent/cordis.yml @@ -0,0 +1,28 @@ +# Full-screen TUI front door over the same repl-agent composition used by the +# readline REPL. The include keeps backends and optional tools aligned; the +# patch owns only the terminal-specific app config. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../repl-agent/cordis.yml + patches: + - id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'TUI agent ready. Give it a coding task.' + ui: + mode: tui + tui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. diff --git a/examples/tui-agent/package.json b/examples/tui-agent/package.json new file mode 100644 index 0000000000..f45e6746a3 --- /dev/null +++ b/examples/tui-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "tui-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: the coding agent through the full-screen terminal UI" +} diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts new file mode 100644 index 0000000000..c55b3d355c --- /dev/null +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -0,0 +1,61 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' +const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` +const FINAL_TEXT = 'Decision received. Scripted TUI run complete.' + +function textChunks(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: text.length } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** Keyless two-step adapter for the real-PTY TUI conversation test. */ +class ScriptedTuiAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false + if (hasToolResult) { + for (const chunk of textChunks(FINAL_TEXT)) yield chunk + return + } + + const args = JSON.stringify({ + questions: [{ + id: 'mode', + header: 'Execution mode', + question: 'How should the scripted run proceed?', + options: [ + { label: 'Safe', description: 'Use the guarded path.' }, + { label: 'Fast', description: 'Use the shorter path.' }, + ], + }], + }) + const callId = CallId('call-ask-mode') + yield { type: 'block-start', index: 0, blockType: 'text' } + for (const char of INITIAL_TEXT) yield { type: 'text-delta', index: 0, text: char } + yield { type: 'block-end', index: 0, block: { type: 'text', text: INITIAL_TEXT } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 1, id: callId, name: 'ask_user_question', argumentsDelta: args } + yield { + type: 'block-end', + index: 1, + block: { type: 'tool-call', id: callId, name: 'ask_user_question', arguments: args }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +export const name = 'tui-scripted-llm' +export const inject = ['llm'] + +/** Register the network-free adapter used by the PTY fixture. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tui-scripted'], new ScriptedTuiAdapter()) +} diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml new file mode 100644 index 0000000000..e40405524e --- /dev/null +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -0,0 +1,27 @@ +# Real Loader composition for the keyless conversational PTY test. The app +# bundle supplies the production agent/TUI/user-question stack; only the model +# is scripted so the terminal interaction is deterministic and network-free. +- id: scripted-llm + name: './tui-scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: tui-scripted + model: tui-scripted-model + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'scripted TUI ready.' + ui: + mode: tui + tui: + showReasoning: true diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl new file mode 100644 index 0000000000..e53f4b3da3 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -0,0 +1,98 @@ +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk"} +{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352051421,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352051422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352051618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352051618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783352051645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":13,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":14,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":15,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":16,"time":1783352051676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":17,"time":1783352051676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1783352051703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":21,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":22,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352051790,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1783352051791,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":33,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":34,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":35,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":36,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783352051905,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":38,"time":1783352051906,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783352051906,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":40,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":44,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":45,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":46,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":47,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":48,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":49,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":50,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":51,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" terminal"}}} +{"type":"assistant/chunk","seq":52,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" access"}}} +{"type":"assistant/chunk","seq":53,"time":1783352052054,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783352052054,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":55,"time":1783352052117,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":56,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1783352052121,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} +{"type":"tool/result","seq":61,"time":1783352052136,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783352052137,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1783352052137,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783352052701,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1783352052702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":67,"time":1783352052809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":68,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":69,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":70,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":71,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":72,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} +{"type":"assistant/chunk","seq":73,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} +{"type":"assistant/chunk","seq":74,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":75,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":76,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":77,"time":1783352052895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1783352052896,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":79,"time":1783352052924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":80,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":81,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":82,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":83,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":85,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":87,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":88,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":89,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":90,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"step/end","seq":95,"time":1783352052987,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":96,"time":1783352052987,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt new file mode 100644 index 0000000000..29ea17fd66 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt @@ -0,0 +1,73 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=27 bufferRow=27 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: bash-terminal-card │" + style 0-0 fg=bright-blue + style 2-36 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " The user wants me to run a simple bash command and then reply with \"DONE\". " + style 1-74 fg=bright-black italic +13| +14| "▌ " + style 0-0 fg=green +15| "▌ ✓ echo TERMINAL_OK " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-19 bold +16| "▌ Echo TERMINAL_OK to verify terminal access " + style 0-0 fg=green + style 2-43 fg=bright-black +17| "▌ TERMINAL_OK " + style 0-0 fg=green +18| "▌ [exit 0] " + style 0-0 fg=green + style 2-9 dim +19| "▌ " + style 0-0 fg=green +20| +21| " Reasoning " + style 1-9 fg=bright-black italic +22| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 1-91 fg=bright-black italic +23| +24| " Assistant " + style 1-9 fg=bright-magenta bold +25| " DONE " +26| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +27| " " + style 1-1 inverse +28| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact" + style 0-58 dim + style 67-99 dim +30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl new file mode 100644 index 0000000000..0fe57055e8 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -0,0 +1,150 @@ +{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR"} +{"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783611771978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783611772007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783611772008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":9,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":10,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":12,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":13,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":14,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":15,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":16,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":17,"time":1783611772096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1783611772124,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":19,"time":1783611772153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":20,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":21,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":22,"time":1783611772211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":23,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":24,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":26,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":27,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":28,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":30,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":31,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":32,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":33,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":34,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":35,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":36,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":37,"time":1783611772362,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":39,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":43,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":44,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":45,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":46,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":47,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":48,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":49,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":50,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":51,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":52,"time":1783611772478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":53,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":54,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":55,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":56,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":57,"time":1783611772508,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":58,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":59,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":60,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":61,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":62,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":63,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":64,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":65,"time":1783611772566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":66,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":67,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":68,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":69,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":70,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":71,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":72,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":73,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":74,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":75,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":76,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":77,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":78,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":79,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":80,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":81,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":82,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":83,"time":1783611772654,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":84,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":85,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":86,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":87,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":88,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":89,"time":1783611772684,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":90,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":91,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":92,"time":1783611772713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":93,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":94,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":95,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":96,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":97,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":98,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":99,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":100,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":101,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":102,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":103,"time":1783611772772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783611772773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":105,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."}}}} +{"type":"assistant/chunk","seq":106,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":107,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":108,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":109,"time":1783611772840,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."},{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[110],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":117,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":118,"time":1783611773480,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":119,"time":1783611773511,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":120,"time":1783611773512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":121,"time":1783611773540,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":122,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":123,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":124,"time":1783611773569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":125,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":126,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":127,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":128,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":129,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":130,"time":1783611773626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":131,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":132,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":133,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":134,"time":1783611773654,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":135,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":136,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":137,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":138,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":139,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":140,"time":1783611773657,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":141,"time":1783611773685,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":142,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":143,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":144,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":145,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":146,"time":1783611773687,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} +{"type":"step/end","seq":147,"time":1783611773687,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":148,"time":1783611773687,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt new file mode 100644 index 0000000000..2850e0c7d0 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt @@ -0,0 +1,79 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=29 bufferRow=29 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: code-mode │" + style 0-0 fg=bright-blue + style 2-27 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 0-0 fg=bright-blue + style 65-77 fg=cyan + style 92-99 fg=cyan +9| "▌ CODE_TWO — and return the two outputs joined with a plus sign. Then reply with that joined string " + style 0-0 fg=bright-blue + style 2-9 fg=cyan +10| "▌ only and stop. " + style 0-0 fg=bright-blue +11| "▌ " + style 0-0 fg=bright-blue +12| +13| " Reasoning " + style 1-9 fg=bright-black italic +14| " The user wants a single run_code program that calls bash twice, then returns the two outputs " + style 1-99 fg=bright-black italic +15| " joined with a plus sign. Let me write this. " + style 1-43 fg=bright-black italic +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-99 bold +19| "▌ const o " + style 0-0 fg=green + style 2-8 bold +20| "▌ CODE_ONE+CODE_TWO " + style 0-0 fg=green +21| "▌ " + style 0-0 fg=green +22| +23| " Reasoning " + style 1-9 fg=bright-black italic +24| " The output is exactly what the user asked for: CODE_ONE+CODE_TWO " + style 1-64 fg=bright-black italic +25| +26| " Assistant " + style 1-9 fg=bright-magenta bold +27| " CODE_ONE+CODE_TWO " +28| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +29| " " + style 1-1 inverse +30| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact" + style 0-49 dim + style 67-99 dim +32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl new file mode 100644 index 0000000000..25a6f76411 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl new file mode 100644 index 0000000000..45d9043a4a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl new file mode 100644 index 0000000000..39f867984a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt new file mode 100644 index 0000000000..e4e6d0a040 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt @@ -0,0 +1,116 @@ +terminal 100x36 buffer=normal length=50 base=14 viewport=14 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=47 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: cordis-dynamic-toolchain │" + style 0-0 fg=bright-blue + style 2-42 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " + style 0-0 fg=bright-blue +9| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " + style 0-0 fg=bright-blue +10| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " + style 0-0 fg=bright-blue +11| "▌ reply with exactly ADVANCED_ACP_OK. " + style 0-0 fg=bright-blue +12| "▌ " + style 0-0 fg=bright-blue +13| +14| "▌ " + style 0-0 fg=green +15| "▌ ✓ Mount plugin into live cordis runtime " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-40 bold +16| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " + style 0-0 fg=green +17| "▌ " + style 0-0 fg=green +18| +19| "▌ " + style 0-0 fg=green +20| "▌ ✓ return await tools.cordis_inspect({ what: 'dynamic' }) " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-57 bold +21| "▌ ## dynamic " + style 0-0 fg=green +22| "▌ - dyn-1: snapshot-marker [active] " + style 0-0 fg=green +23| "▌ " + style 0-0 fg=green +24| +25| "▌ " + style 0-0 fg=green +26| "▌ ✓ subagent " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-11 bold +27| "▌ DIRECT_CHILD_OK " + style 0-0 fg=green +28| "▌ " + style 0-0 fg=green +29| +30| "▌ " + style 0-0 fg=green +31| "▌ ✓ workflow: advanced-acp-snapshot " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-34 bold +32| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " + style 0-0 fg=green +33| "▌ Return value: " + style 0-0 fg=green +34| "▌ { " + style 0-0 fg=green +35| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " + style 0-0 fg=green +36| "▌ } " + style 0-0 fg=green +37| "▌ " + style 0-0 fg=green +38| +39| "▌ " + style 0-0 fg=green +40| "▌ ✓ Unmount dyn-1 " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-16 bold +41| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " + style 0-0 fg=green +42| "▌ " + style 0-0 fg=green +43| +44| " Assistant " + style 1-9 fg=bright-magenta bold +45| " ADVANCED_ACP_OK " +46| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +47| " " + style 1-1 inverse +48| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact" + style 0-61 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl new file mode 100644 index 0000000000..3d89428bbd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -0,0 +1,36 @@ +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8"} +{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600638173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":15,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":16,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":25,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":26,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl new file mode 100644 index 0000000000..3e0ae3da73 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -0,0 +1,209 @@ +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz"} +{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":13,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":19,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":21,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":22,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":23,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":24,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":26,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":27,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":28,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} +{"type":"assistant/chunk","seq":30,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} +{"type":"assistant/chunk","seq":31,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":32,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"so"}}} +{"type":"assistant/chunk","seq":33,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":34,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":35,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":36,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} +{"type":"assistant/chunk","seq":37,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":38,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} +{"type":"assistant/chunk","seq":39,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":40,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meta"}}} +{"type":"assistant/chunk","seq":42,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":43,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":44,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"name"}}} +{"type":"assistant/chunk","seq":46,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":47,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":48,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sn"}}} +{"type":"assistant/chunk","seq":49,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"apshot"}}} +{"type":"assistant/chunk","seq":50,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-flow"}}} +{"type":"assistant/chunk","seq":51,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":52,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":53,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} +{"type":"assistant/chunk","seq":54,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":55,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":56,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":57,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":58,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":59,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":61,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" }\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":64,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":66,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":67,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":68,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} +{"type":"assistant/chunk","seq":69,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":70,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":71,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":72,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":73,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":75,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":76,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":77,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":78,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":82,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":83,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":84,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":85,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":87,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":88,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":90,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":91,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":92,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":94,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":95,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":97,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"meta"}}} +{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":99,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":100,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":101,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"name"}}} +{"type":"assistant/chunk","seq":102,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":103,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":104,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":105,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":106,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":107,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":108,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":109,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":110,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":111,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":113,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":114,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":115,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":117,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":119,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":122,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":123,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":124,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":126,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":127,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":128,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":129,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":130,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":131,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":132,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":133,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":134,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":135,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":136,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":137,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":138,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":139,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":140,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":141,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":142,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":143,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":144,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":145,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":146,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":147,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":148,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":149,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":150,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":151,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":152,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" }\\n"}}} +{"type":"assistant/chunk","seq":153,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} +{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} +{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} +{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} +{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":165,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":166,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":167,"time":1783600640162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":168,"time":1783600640195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":169,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":170,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":172,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":174,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":175,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":176,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":177,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":178,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":179,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":180,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":181,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":182,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":183,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":184,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":185,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":186,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":187,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":188,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":189,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":190,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":191,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":192,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":193,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":194,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":196,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":197,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":199,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt new file mode 100644 index 0000000000..459fddd90c --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt @@ -0,0 +1,106 @@ +terminal 100x36 buffer=normal length=47 base=11 viewport=11 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=44 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: dynamic-workflow │" + style 0-0 fg=bright-blue + style 2-34 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " + style 0-0 fg=bright-blue +9| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " + style 0-0 fg=bright-blue +10| "▌ phase('Run') " + style 0-0 fg=bright-blue +11| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " + style 0-0 fg=bright-blue +12| "▌ return { reply } " + style 0-0 fg=bright-blue +13| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " + style 0-0 fg=bright-blue +14| "▌ other tool. " + style 0-0 fg=bright-blue +15| "▌ " + style 0-0 fg=bright-blue +16| +17| " Reasoning " + style 1-9 fg=bright-black italic +18| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 1-99 fg=bright-black italic +19| " follow the instructions: " + style 1-24 fg=bright-black italic +20| " " +21| " 1. args omitted (so I don't include it) " + style 1-3 fg=bright-blue + style 4-39 fg=bright-black italic +22| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 1-3 fg=bright-blue + style 4-82 fg=bright-black italic +23| " 3. script = as given verbatim " + style 1-3 fg=bright-blue + style 4-29 fg=bright-black italic +24| " 4. After it returns, reply with \"WORKFLOW_DONE\" " + style 1-3 fg=bright-blue + style 4-47 fg=bright-black italic +25| " " +26| " Let me do exactly that. " + style 1-23 fg=bright-black italic +27| +28| "▌ " + style 0-0 fg=green +29| "▌ ✓ workflow: snapshot-flow " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-26 bold +30| "▌ workflow \"snapshot-flow\" completed (1 agent). " + style 0-0 fg=green +31| "▌ Return value: " + style 0-0 fg=green +32| "▌ { " + style 0-0 fg=green +33| "▌ \"reply\": \"WF_CHILD_OK\" " + style 0-0 fg=green +34| "▌ } " + style 0-0 fg=green +35| "▌ " + style 0-0 fg=green +36| +37| " Reasoning " + style 1-9 fg=bright-black italic +38| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 1-99 fg=bright-black italic +39| " \"WORKFLOW_DONE\" and stop. " + style 1-25 fg=bright-black italic +40| +41| " Assistant " + style 1-9 fg=bright-magenta bold +42| " WORKFLOW_DONE " +43| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +44| " " + style 1-1 inverse +45| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact" + style 0-56 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl new file mode 100644 index 0000000000..cd32072eaa --- /dev/null +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -0,0 +1,65 @@ +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR"} +{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352114542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352114570,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352114572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352114600,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352114601,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":17,"time":1783352114603,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783352114627,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1783352114628,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":20,"time":1783352114657,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":21,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":25,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} +{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} +{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1783352114690,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":31,"time":1783352114690,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":32,"time":1783352114699,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":33,"time":1783352114699,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":34,"time":1783352114700,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":35,"time":1783352115341,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":36,"time":1783352115341,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":37,"time":1783352115465,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":38,"time":1783352115492,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":39,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":40,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":44,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":45,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":46,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":47,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} +{"type":"assistant/chunk","seq":48,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":49,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":51,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":52,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":53,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":55,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} +{"type":"assistant/chunk","seq":56,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":57,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} +{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783352115611,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":63,"time":1783352115611,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt new file mode 100644 index 0000000000..d56db72ccb --- /dev/null +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt @@ -0,0 +1,70 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=28 bufferRow=28 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: multi-turn-conversation │" + style 0-0 fg=bright-blue + style 2-41 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Reply with exactly the word: ONE. No tools. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 1-72 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " ONE " +16| +17| "▌ " + style 0-0 fg=bright-blue +18| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +19| "▌ Reply with exactly the word: TWO. No tools. " + style 0-0 fg=bright-blue +20| "▌ " + style 0-0 fg=bright-blue +21| +22| " Reasoning " + style 1-9 fg=bright-black italic +23| " The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 1-68 fg=bright-black italic +24| +25| " Assistant " + style 1-9 fg=bright-magenta bold +26| " TWO " +27| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +28| " " + style 1-1 inverse +29| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact" + style 0-62 dim + style 67-99 dim +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl new file mode 100644 index 0000000000..81503dc4cf --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt new file mode 100644 index 0000000000..f4c3d83b3d --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt @@ -0,0 +1,91 @@ +terminal 100x36 buffer=normal length=39 base=3 viewport=3 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=36 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: parallel-file-reads │" + style 0-0 fg=bright-blue + style 2-37 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| "▌ " + style 0-0 fg=green +12| "▌ ✓ Read a.txt " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-13 bold +13| "▌ /workspace/project/a.txt " + style 0-0 fg=green +14| "▌ file " + style 0-0 fg=green +15| "▌ " + style 0-0 fg=green +16| "▌ 1: alpha " + style 0-0 fg=green +17| "▌ " + style 0-0 fg=green +18| "▌ (End of file - total 1 lines) " + style 0-0 fg=green +19| "▌ " + style 0-0 fg=green +20| "▌ " + style 0-0 fg=green +21| +22| "▌ " + style 0-0 fg=green +23| "▌ ✓ Read b.txt " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-13 bold +24| "▌ /workspace/project/b.txt " + style 0-0 fg=green +25| "▌ file " + style 0-0 fg=green +26| "▌ " + style 0-0 fg=green +27| "▌ 1: beta " + style 0-0 fg=green +28| "▌ " + style 0-0 fg=green +29| "▌ (End of file - total 1 lines) " + style 0-0 fg=green +30| "▌ " + style 0-0 fg=green +31| "▌ " + style 0-0 fg=green +32| +33| " Assistant " + style 1-9 fg=bright-magenta bold +34| " DONE " +35| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +36| " " + style 1-1 inverse +37| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact" + style 0-55 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt new file mode 100644 index 0000000000..4a58007052 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt @@ -0,0 +1 @@ +alpha diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt new file mode 100644 index 0000000000..65b2df87f7 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt @@ -0,0 +1 @@ +beta diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl new file mode 100644 index 0000000000..909afc44cd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -0,0 +1,134 @@ +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7"} +{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352058466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todo"}}} +{"type":"assistant/chunk","seq":13,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_write"}}} +{"type":"assistant/chunk","seq":14,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":15,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":16,"time":1783352058485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" record"}}} +{"type":"assistant/chunk","seq":17,"time":1783352058511,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":18,"time":1783352058512,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plan"}}} +{"type":"assistant/chunk","seq":19,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":21,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" three"}}} +{"type":"assistant/chunk","seq":22,"time":1783352058514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":23,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":24,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783352058571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":26,"time":1783352058572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" status"}}} +{"type":"assistant/chunk","seq":27,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":28,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":29,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":30,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":31,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":33,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":34,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":35,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":36,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"t"}}} +{"type":"assistant/chunk","seq":41,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"odos"}}} +{"type":"assistant/chunk","seq":42,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783352058776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"["}}} +{"type":"assistant/chunk","seq":45,"time":1783352058805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":46,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":47,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":48,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":49,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"read"}}} +{"type":"assistant/chunk","seq":50,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":51,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" code"}}} +{"type":"assistant/chunk","seq":52,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":53,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":55,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":56,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":57,"time":1783352058862,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"in"}}} +{"type":"assistant/chunk","seq":58,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"_pro"}}} +{"type":"assistant/chunk","seq":59,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"gress"}}} +{"type":"assistant/chunk","seq":60,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":61,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":62,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":63,"time":1783352058889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":64,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":65,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"write"}}} +{"type":"assistant/chunk","seq":66,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":67,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" fix"}}} +{"type":"assistant/chunk","seq":68,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":69,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":70,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":71,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":72,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":73,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} +{"type":"assistant/chunk","seq":74,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":75,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":76,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":77,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":78,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":79,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"run"}}} +{"type":"assistant/chunk","seq":80,"time":1783352058948,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" tests"}}} +{"type":"assistant/chunk","seq":82,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":83,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":85,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":86,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":87,"time":1783352059004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} +{"type":"assistant/chunk","seq":88,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":90,"time":1783352059033,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":91,"time":1783352059095,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":92,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} +{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} +{"type":"tool/call","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} +{"type":"todo/write","seq":97,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} +{"type":"tool/result","seq":98,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[96],"surfaceOp":"append"} +{"type":"step/end","seq":99,"time":1783352059101,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":100,"time":1783352059102,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":101,"time":1783352059732,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":102,"time":1783352059733,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":103,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":104,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":105,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":106,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" written"}}} +{"type":"assistant/chunk","seq":107,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":108,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":109,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":110,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":111,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":112,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":113,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":114,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":115,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":116,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":117,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":118,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":119,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":120,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":121,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":122,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":123,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":124,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":125,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":126,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} +{"type":"step/end","seq":131,"time":1783352059981,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":132,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt new file mode 100644 index 0000000000..527e6e4016 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt @@ -0,0 +1,81 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=33 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: todo-plan │" + style 0-0 fg=bright-blue + style 2-27 fg=bright-black + style 99-99 fg=bright-blue +3| "│ deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-35 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " + style 0-0 fg=bright-blue +9| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " + style 0-0 fg=bright-blue +10| "▌ reply with the single word DONE and stop. " + style 0-0 fg=bright-blue +11| "▌ " + style 0-0 fg=bright-blue +12| +13| " Reasoning " + style 1-9 fg=bright-black italic +14| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 1-99 fg=bright-black italic +15| " specified statuses, then reply with \"DONE\". " + style 1-43 fg=bright-black italic +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ Update todo list " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-19 bold +19| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " + style 0-0 fg=green +20| "▌ " + style 0-0 fg=green +21| +22| " Reasoning " + style 1-9 fg=bright-black italic +23| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 1-95 fg=bright-black italic +24| +25| " Assistant " + style 1-9 fg=bright-magenta bold +26| " DONE " +27| +28| "Plan" + style 0-3 fg=bright-blue bold +29| " ● read the code" + style 2-2 fg=yellow +30| " ○ write the fix" + style 2-2 dim +31| " ○ run the tests" + style 2-2 dim +32| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +33| " " + style 1-1 inverse +34| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact" + style 0-49 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts new file mode 100644 index 0000000000..7cc8aebe32 --- /dev/null +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -0,0 +1,172 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +const PTY_DRIVER = String.raw` +import errno, json, os, pty, select, signal, sys, time +node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario = sys.argv[1:] +env = os.environ.copy() +env.update(json.loads(launch_env_json)) +env.update({ + "COLUMNS": "100", + "LINES": "30", +}) +if resume_session_id: + env["RESUME_SESSION_ID"] = resume_session_id +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, *json.loads(launch_args_json)], env) + +output = bytearray() +answered_question = False +sent_prompt = False +sent_exit = False +deadline = time.monotonic() + 25 +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + if scenario == "conversation" and not sent_prompt and b"scripted TUI ready." in output: + os.write(fd, b"exercise the TUI\r") + sent_prompt = True + if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: + os.write(fd, b"\r") + answered_question = True + if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: + os.write(fd, b"/exit\r") + sent_exit = True + if scenario == "boot" and not sent_exit and b"TUI agent ready." in output: + os.write(fd, b"/exit\r") + sent_exit = True + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if scenario == "resume-failure": + if b'ui-tui: session "missing-session" failed to start:' not in output: + sys.stderr.write("TUI did not render the startup failure before timeout\n") + sys.exit(126) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: + sys.stderr.write("TUI startup failure did not exit with status 1\n") + sys.exit(127) +elif scenario == "conversation": + if not sent_prompt: + sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") + sys.exit(128) + if not answered_question: + sys.stderr.write("TUI did not render the user-question dialog before timeout\n") + sys.exit(129) + if not sent_exit: + sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") + sys.exit(130) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI scripted conversation did not exit cleanly\n") + sys.exit(131) +else: + if not sent_exit: + sys.stderr.write("TUI did not render its welcome marker before timeout\n") + sys.exit(124) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI child did not exit cleanly\n") + sys.exit(125) +` + +interface TuiLoaderSmokeOptions { + config?: string + resumeSessionId?: string + scenario?: 'boot' | 'conversation' | 'resume-failure' +} + +async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { + const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) + try { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [options.config ?? configPath], + tsconfigPath, + exposeInternals: true, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + options.resumeSessionId ?? '', + options.scenario ?? 'boot', + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} + +describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { + it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { + const output = await runTuiLoaderSmoke() + expect(output).toContain('DEEPSEEK') + expect(output).toContain('TUI agent ready.') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) + expect(output).toContain('I need one decision before I continue.') + expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) + expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) + expect(output).toContain(String.raw`\x9b31mMODEL_C1`) + expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') + expect(output).not.toContain('\u001B[999CMODEL_CURSOR') + expect(output).not.toContain('\u009B31mMODEL_C1') + expect(output).toContain('How should the scripted run proceed?') + expect(output).toContain('Safe') + expect(output).toContain('Decision received. Scripted TUI run complete.') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { + const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) + expect(output).toContain('ui-tui: session "missing-session" failed to start:') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts new file mode 100644 index 0000000000..c0537021e8 --- /dev/null +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -0,0 +1,330 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import { createTuiChat } from '@deepseek-ai/dsh-tui' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' + +const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +// Keep pre-normalization layout widths identical across macOS and Linux. +const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp' +const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash' }] }] +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + +type SnapshotMode = 'replay' | 'record' | 'refresh' +type Composition = 'native' | 'code' | 'advanced' + +interface Scenario { + name: string + composition: Composition + expectedTools: string[] + expectedEventCounts?: Record + childSessions?: number + recorded: boolean + seedWorkspace?: boolean +} + +const SCENARIOS: Scenario[] = [ + { + name: 'multi-turn-conversation', + composition: 'native', + expectedTools: [], + recorded: true, + }, + { + name: 'todo-plan', + composition: 'native', + expectedTools: ['todo_write'], + expectedEventCounts: { 'todo/write': 1 }, + recorded: true, + }, + { + name: 'bash-terminal-card', + composition: 'native', + expectedTools: ['bash'], + recorded: true, + }, + { + name: 'parallel-file-reads', + composition: 'native', + expectedTools: ['read', 'read'], + recorded: true, + seedWorkspace: true, + }, + { + name: 'code-mode', + composition: 'code', + expectedTools: ['run_code'], + expectedEventCounts: { 'tool/code-dispatch': 2 }, + recorded: true, + }, + { + name: 'dynamic-workflow', + composition: 'native', + expectedTools: ['workflow'], + childSessions: 1, + recorded: true, + }, + { + name: 'cordis-dynamic-toolchain', + composition: 'advanced', + expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'], + expectedEventCounts: { 'tool/code-dispatch': 1 }, + childSessions: 2, + recorded: false, + }, +] + +function snapshotModeFromEnv(value: string | undefined): SnapshotMode { + if (value === undefined || value === '' || value === 'replay') return 'replay' + if (value === 'record' || value === 'refresh') return value + throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) +} + +const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) +const observedScenarios = new Set() + +function scenarioDir(scenario: Scenario): string { + return join(SNAPSHOTS_DIR, scenario.name) +} + +function childFixturePaths(scenario: Scenario): string[] { + return Array.from( + { length: scenario.childSessions ?? 0 }, + (_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`), + ) +} + +function userPrompts(rawLog: string): string[] { + return parseSessionLog(rawLog).flatMap((event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] + const text = event.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + return text.length > 0 ? [text] : [] + }) +} + +function rawSessionLog(session: Session): string { + return [ + JSON.stringify({ type: 'session', ...session.header }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +function normalizeTerminalSnapshot(snapshot: string, cwd: string): string { + return snapshot + .split(`/private${cwd}`).join('/workspace/project') + .split(cwd).join('/workspace/project') + .replace(UUID_RE, '{{uuid}}') +} + +async function settleTerminal(terminal: HeadlessTerminal): Promise { + let stable = 0 + for (let attempt = 0; attempt < 20 && stable < 3; attempt++) { + const before = terminal.frames + await new Promise(resolve => setTimeout(resolve, 10)) + await terminal.flush() + stable = terminal.frames === before ? stable + 1 : 0 + } + if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms') +} + +async function mountScenarioContext( + scenario: Scenario, + cwd: string, + fixtureFile: string, + childFiles: string[], +): Promise { + const ctx = new Context() + await ctx.plugin(AgentCore, { + agents: [], + dshHome: join(cwd, '.dsh'), + workspaceContext: false, + tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, + skills: { local: { agentsHome: join(cwd, '.agents') } }, + }) + await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + await ctx.plugin(UserInteractionService) + await ctx.plugin(ToolTodo) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) + await ctx.plugin(ToolWorkflow) + if (scenario.composition === 'code' || scenario.composition === 'advanced') { + await ctx.plugin(WorkerCodeRuntime, {}) + } + if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) + if (MODE === 'record' && scenario.recorded) { + await ctx.plugin(LlmDeepSeek) + } else { + installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS }) + } + return ctx +} + +interface ScenarioResult { + terminal: string + parent: Session + children: Session[] + workflowEvents: string[] +} + +async function runScenario(scenario: Scenario): Promise { + const dir = scenarioDir(scenario) + const fixtureFile = join(dir, 'session.jsonl') + const childFiles = childFixturePaths(scenario) + const fixture = await readFile(fixtureFile, 'utf8') + const prompts = userPrompts(fixture) + expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + + const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) + let ctx: Context | undefined + let controller: ReturnType | undefined + const terminal = new HeadlessTerminal(100, 36) + try { + if (scenario.seedWorkspace === true) { + const source = join(scenarioDir(scenario), 'workspace') + await cp(source, cwd, { recursive: true }) + } + ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles) + const disposedSessions: Session[] = [] + ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) + const workflowEvents: string[] = [] + for (const name of ['workflow/start', 'workflow/phase', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { + ctx.on(name, () => { workflowEvents.push(name) }) + } + const handle = await ctx.agents.create({ + sessionId: SessionId('main-session'), + meta: { cwd }, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }) + const agent: Agent = handle.agent + controller = createTuiChat(ctx, { + sessionId: 'main-session', + color: true, + showReasoning: true, + title: 'DSH TUI snapshot', + welcome: `Recorded replay: ${scenario.name}`, + maxToolOutputLines: 8, + }, { terminal, exit: () => {} }) + await settleTerminal(terminal) + + for (const prompt of prompts) { + terminal.send(prompt) + terminal.send('\r') + await agent.whenIdle() + await settleTerminal(terminal) + } + + const events: SessionEvent[] = [...agent.session.events] + expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) + for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { + expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) + } + expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) + expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true) + if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') { + expect(workflowEvents).toEqual([ + 'workflow/start', + 'workflow/phase', + 'workflow/agent-start', + 'workflow/agent-end', + 'workflow/end', + ]) + } + + expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([]) + const snapshot = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + ) + await handle.dispose() + const children = disposedSessions + .filter(session => session !== agent.session) + .sort((a, b) => a.header.createdAt - b.header.createdAt) + expect(children).toHaveLength(scenario.childSessions ?? 0) + return { terminal: snapshot, parent: agent.session, children, workflowEvents } + } finally { + await controller?.dispose() + await ctx?.fiber.dispose() + await terminal.dispose() + await rm(cwd, { recursive: true, force: true }) + } +} + +async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { + const dir = scenarioDir(scenario) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'session.jsonl'), scrubRequestHeaders(rawSessionLog(result.parent))) + expect(result.children).toHaveLength(scenario.childSessions ?? 0) + for (const [index, child] of result.children.entries()) { + await writeFile(join(dir, `session.${index + 1}.jsonl`), scrubRequestHeaders(rawSessionLog(child))) + } +} + +describe('TUI recorded-session terminal snapshots', () => { + for (const scenario of SCENARIOS) { + it(scenario.name, async () => { + observedScenarios.add(scenario.name) + const result = await runScenario(scenario) + const terminalFile = join(scenarioDir(scenario), 'terminal.golden.txt') + if (MODE === 'record' || MODE === 'refresh') { + await mkdir(scenarioDir(scenario), { recursive: true }) + await writeFile(terminalFile, result.terminal) + } + if (MODE === 'record' && scenario.recorded) await writeRecording(scenario, result) + await expect(result.terminal).toMatchFileSnapshot(terminalFile) + }, 120_000) + } +}) + +afterAll(async () => { + expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) + const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) + for (const scenario of SCENARIOS) { + const expected = [ + 'session.jsonl', + 'terminal.golden.txt', + ...scenario.seedWorkspace === true ? ['workspace'] : [], + ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`), + ].sort() + expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected) + for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) { + const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8') + expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content) + } + } +}) diff --git a/knip.json b/knip.json index ad83bbbd2e..2c3465ab38 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], - "ignoreBinaries": ["bwrap", "sandbox-exec"], + "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime", "website"], "workspaces": { ".": { @@ -10,6 +10,8 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "headless-agent/tests/fixtures/cli-mock-llm.ts", + "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -105,14 +107,26 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/jsonrpc": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/stdio-demo": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/examples/cli-demo": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/ui/stdio": { "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/tui": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/jsonrpc-demo": { "project": ["src/**/*.ts"] }, diff --git a/package.json b/package.json index 39d6a53161..e736e3f8d1 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,9 @@ "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", - "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/coding-agent/cordis.yml", + "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", + "demo:headless": "node --expose-internals --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", + "demo:tui": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 766a188105..c41c25b7a5 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -6,12 +6,21 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. +- **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md)). - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. +- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). +- **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. +- **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. +- **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. +- **Enforce at the operation boundary that owns the decision.** Schema omission, prompt filtering, facades, wrappers, and listener order are not enforcement when direct or alternate callers can bypass them; test denial through the executor. +- **Publish state only at its commit point.** Emit each notification and update derived state only after the success boundary that makes it true; derive caches, prompts, UI echoes, replay, and query views from one authoritative source. +- **Apply bounds to the complete result.** Enforce byte, token, item, and time limits where the complete emitted or retained value, including wrappers and metadata, is known; test tiny and exact limits, oversized single chunks, and multibyte byte limits. +- **Registry contributions prove disposal.** Add the HMR-safety test required by the [testing policy](../docs/testing.md): dispose the contributing fiber and observe removal. Naming notes: - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. -- Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/README.md b/packages/README.md index 6c83abfc91..61a1e994c8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -8,7 +8,7 @@ Packages live at `packages///`; groups are containers, while names r | Group | Role | Release expectation | |---|---|---| -| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | +| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface | @@ -31,8 +31,8 @@ Packages live at `packages///`; groups are containers, while names r | [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | -| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra | -| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes, subagent mock) | Support — lower compatibility expectations | +| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | +| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | Groups distinguish product API from support infrastructure. New packages join an existing group; a new group updates its README and this table. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index afa5000767..fcd1317b75 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -31,6 +31,10 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-process deltas, spill-file paths, and infrastructure failures. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 347aefb679..fc6e82bcae 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -38,21 +38,45 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc ### Bash tool schema, indirectly -**What the model sees**: The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated. +#### What the model sees -**Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens. +The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated. + +#### Token effect + +Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens. + +#### KV Cache effect + +Prefix-stable while the executor advertises the same sandbox capabilities. Changing those capabilities alters the `bash` schema and may invalidate reuse from that definition; per-session mode switches do not. ### Bash tool result, indirectly -**What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under mode — the command did not run; this is a sandbox problem, not a command failure]`. +#### What the model sees -**Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction. +After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under mode — the command did not run; this is a sandbox problem, not a command failure]`. + +#### Token effect + +Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Bash tool error, indirectly -**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. +#### What the model sees -**Token effect**: Conditional error text is visible for that call and retained in history until compaction. +If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. + +#### Token effect + +Conditional error text is visible for that call and retained in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index e4b5bf1952..9a3cbc46f1 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -37,6 +37,10 @@ The seam also owns the per-session mode override vocabulary: the log-only `'bash Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept. diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index d5e65f1a39..3eea649643 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -73,39 +73,79 @@ For sandboxing executors, each call resolves mode as one-shot escalation, then s ### System prompt -**What the model sees**: Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section. +#### What the model sees -**Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches. +Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section. -#### Bash guidance +##### Bash guidance ```markdown Check the [exit code: N] marker on every bash result; investigate failures before moving on. ``` +#### Token effect + +Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches. + +#### KV Cache effect + +Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section; sandbox mode switches do not. + ### Tool schemas -**What the model sees**: The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent. +#### What the model sees -**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph. +The model sees the generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `run_in_background` appears only when this producer enables it; `sandbox_permissions` and `justification` appear only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definition for that agent. + +#### Token effect + +Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph. + +#### KV Cache effect + +Prefix-stable while visibility, background support, and executor sandbox capabilities are unchanged. A restriction, config change, or executor change may invalidate reuse from the first changed tool definition. ### Foreground result -**What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md). +#### What the model sees -**Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md). + +#### Token effect + +Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Background task context and results -**What the model sees**: Start returns exactly `started background task `. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: ]`, sandbox facts, and terminal detail such as `exit code: ` or `signal: ` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response. +#### What the model sees -**Token effect**: The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output. +Start returns exactly `started background task `. This producer supplies incremental process output, optional `[some output was dropped from memory; full output: ]`, sandbox facts, and terminal detail such as `exit code: ` or `signal: ` to the generic task runtime. [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md) owns the visible status line, completion notice, listing, and cancellation response. + +#### Token effect + +The start acknowledgement is small and retained; collected output is data-dependent and bounded by the executor's stream buffers. Consuming reads do not repeat prior output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Tool errors -**What the model sees**: Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. +#### What the model sees -**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run. +Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `background execution is disabled for this bash tool`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. + +#### Token effect + +Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 1f9055c67a..d01167de3d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -39,7 +39,7 @@ afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -50,7 +50,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -100,11 +100,10 @@ describe('bash tool through the agent loop', () => { ]) const ctx = await harness(adapter, root, dshHome) const handle = await ctx.agents.create({ - agentId: AgentId('session-env'), sessionId: SessionId('session-env-id'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') @@ -125,7 +124,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-fg'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -157,7 +156,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-exit'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -177,7 +176,7 @@ describe('bash tool through the agent loop', () => { textResponse('Background task finished.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-bg'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run echo bg-ok in the background' }]) await waitForIdle(ctx, agent) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c19846ab13..af0cdcaa1c 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -10,7 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -50,18 +50,17 @@ async function setupWithTasks() { } /** - * Build a fake {@link Agent} whose session token is `sessionId`, give it a + * Build a fake {@link Agent} with the shared agent/session identity, give it a * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. - * The agent id is deliberately different from the session token so a - * wrong-field ownership match fails the test. */ function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: `agent-${sessionId}`, + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -183,11 +182,13 @@ async function setupSandboxed(withApproval = false) { function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', ctx?: Context): Agent { const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }] if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } }) + const id = SessionId('sandbox-session') return { - id: 'sandbox-agent', + id, ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx }, session: { - header: { version: 0, id: 'sandbox-session', createdAt: 0 }, + id, + header: { version: 0, id, createdAt: 0 }, events, append: (type: string, data: Record) => { const event = { type, data } diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index 342799dabb..96d65198e2 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -37,6 +37,10 @@ The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. Th Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists. diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 1f680741a1..e5b1565fec 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -22,6 +22,10 @@ Semantics every implementation must honor (contract details in the class JSDoc): Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 937120eebe..6f9f5f387d 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,15 +8,16 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Measurement** — the singleton `ctx.tokenMeter` prices the provisional request envelope and current surface at one consumed-log revision. The current prompt and prefix override their logged values; the pre-step boundary reuses logged tools and call config. +- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. -- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. +- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. -`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. +The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) @@ -30,7 +31,8 @@ Every setting is optional. The pressure and retention policy applies to the toke | `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | -| `auto` | no (default `true`) | Register the `agent/pre-step` automatic listener. Set `false` for manual-only. | +| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | +| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | ## Usage @@ -40,7 +42,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' export const name = 'compact-basic' -export const inject = ['llm'] +export const inject = ['llm', 'tokenMeter'] export function apply(ctx: Context): void { ctx.plugin(TokenMeterService) @@ -54,29 +56,45 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c ### Conversation history -**What the model sees**: Before a step whose estimated envelope and history exceed the threshold, the conversation model receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. This one checkpoint replaces the selected older range and is followed by the retained recent units. +#### What the model sees -**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. +After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units. -#### Conversation checkpoint preamble +##### Conversation checkpoint preamble ```markdown This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint. ``` +#### Token effect + +The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. + +#### KV Cache effect + +Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable. + ### Auxiliary summarizer user message -**What the model sees**: The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. +#### What the model sees -**Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. +The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. + +#### Token effect + +This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. + +#### KV Cache effect + +Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token. ### Auxiliary summarizer system prompt -**What the model sees**: The summarization model receives the checkpoint-writing instruction below. +#### What the model sees -**Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. +The summarization model receives the checkpoint-writing instruction below. -#### Auxiliary summarizer system prompt +##### Auxiliary summarizer system prompt ```markdown You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context. @@ -114,10 +132,19 @@ Rules: - If the transcript already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. ``` +#### Token effect + +Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. + +#### KV Cache effect + +Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction. + ## Known Limitations and Deferred Work -- **Pre-step sees a provisional request envelope** — the current prompt and prefix are exact, but routing and tool changes made later in `agent/request` are not logged yet. A router-only agent with no provisional provider/model pair skips that check. - **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization. +- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. +- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix. - **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. - **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. - **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact-basic/src/automatic.ts b/packages/compact/compact-basic/src/automatic.ts deleted file mode 100644 index edb317d270..0000000000 --- a/packages/compact/compact-basic/src/automatic.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Automatic pre-step pressure listener for compact-basic. - * - * @module @deepseek-ai/dsh-compact-basic/automatic - */ - -import type { Context } from 'cordis' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { Message } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' - -interface AutomaticCompactor { - compactIfNeeded( - agent: Agent, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], - signal: AbortSignal, - ): Promise -} - -/** - * Register the implementation-owned automatic compaction listener. - * @param ctx - context owning the listener effect and logger. - * @param service - compactor whose public methods remain dynamically dispatched. - */ -export function registerAutomaticCompaction( - ctx: Context, - service: AutomaticCompactor, -): void { - ctx.on('agent/pre-step', async ( - agent: Agent, - _turn: number, - _step: number, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], - signal: AbortSignal, - ) => { - try { - const result = await service.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) - if (result !== null) { - ctx.logger.info( - `compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` - + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` - + `~${result.shadowedTokenCount} tokens)`, - ) - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`) - } - }) -} diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 30241987a4..1587fac272 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -22,6 +22,7 @@ const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ 'summarizationModel', 'maxTokens', 'compactionRetries', + 'maxOverflowRetries', 'auto', ]) @@ -31,7 +32,8 @@ function validateConfigKeys(config: BasicCompactConfig): void { if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) { throw new Error( `BasicCompactConfig: unknown key "${key}" ` - + '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, maxTokens, compactionRetries, auto)', + + '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, ' + + 'maxTokens, compactionRetries, maxOverflowRetries, auto)', ) } } @@ -58,6 +60,7 @@ export function resolveConfig( summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, + maxOverflowRetries: config.maxOverflowRetries ?? 1, auto: config.auto ?? true, } @@ -71,6 +74,7 @@ export function resolveConfig( } assertPositiveInteger('maxTokens', resolved.maxTokens) assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries) if (typeof resolved.summarizationProvider !== 'string') { throw new Error('BasicCompactConfig: summarizationProvider must be a string') } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 60452581c5..5d325d57ba 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -7,12 +7,11 @@ import { Context } from 'cordis' import z from 'schemastery' import { CompactService } from '@deepseek-ai/dsh-compact' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import { canonicalHeader } from '@deepseek-ai/dsh-session' -import type { EpochHeader, Session } from '@deepseek-ai/dsh-session' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' +import type { Session } from '@deepseek-ai/dsh-session' +import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { registerAutomaticCompaction } from './automatic.ts' import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' @@ -21,41 +20,15 @@ import type { ResolvedConfig, } from './types.ts' -export { resolveConfig } from './config.ts' export type { BasicCompactConfig, ResolvedConfig, } from './types.ts' -/** Resolve the latest actual routed provider/model, then the complete agent fallback pair. */ -function effectiveTarget(agent: Agent): { provider: string; model: string } | undefined { - const latest = agent.session.requestHeader()?.config - if (latest !== undefined) return { provider: latest.provider, model: latest.model } - const { provider, model } = agent.options - if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) { - return undefined - } - return { provider, model } -} - -/** - * Build the provisional pre-step request envelope. Prompt and prefix are exact; - * tools and non-model call config come from the latest logged request because - * later request middleware has not run yet. - */ -function provisionalHeader( - target: { provider: string; model: string }, - session: Session, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], -): EpochHeader { - const latest = session.requestHeader() - return canonicalHeader({ - config: latest === undefined ? target : { ...latest.config, ...target }, - ...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt }, - ...latest?.tools === undefined ? {} : { tools: latest.tools }, - ...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] }, - }) +/** Resolve the exact model durably routed for the latest provider request. */ +function routedModel(session: Session): string | undefined { + const model = session.requestHeader()?.config.model + return model === undefined || model.length === 0 ? undefined : model } /** @@ -76,6 +49,7 @@ export class BasicCompactService extends CompactService { summarizationModel: z.string().default(''), maxTokens: z.number().step(1).min(1).default(8192), compactionRetries: z.number().step(1).min(0).default(1), + maxOverflowRetries: z.number().step(1).min(0).default(1), auto: z.boolean().default(true), }) @@ -85,7 +59,63 @@ export class BasicCompactService extends CompactService { constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) this.config = resolveConfig(config, ctx.tokenMeter) - if (this.config.auto) registerAutomaticCompaction(ctx, this) + if (this.config.auto) this._registerAutomaticCompaction() + } + + /** + * Register the automatic post-step pressure and context-overflow recovery + * listeners. `compactIfNeeded` stays dynamically dispatched so subclass + * overrides are honored at event time. + */ + private _registerAutomaticCompaction(): void { + const { ctx } = this + const logResult = (result: CompactionResult, trigger: string): void => { + ctx.logger.info( + `compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes ` + + `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` + + `~${result.shadowedTokenCount} tokens)`, + ) + } + + ctx.on('agent/post-step', async ( + agent: Agent, + _turn: number, + _step: number, + signal: AbortSignal, + ) => { + if (signal.aborted) return + try { + const result = await this.compactIfNeeded(agent, 'pressure', signal) + if (result !== null) logResult(result, 'post-step pressure') + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) + } + }) + + ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { + if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE + || retryAttempt >= this.config.maxOverflowRetries + || signal.aborted) return next() + + let generation: number + let result: CompactionResult | null + try { + generation = agent.session.surface.replaceGeneration + result = await this.compactIfNeeded(agent, 'context-overflow', signal) + } catch (recoveryError: unknown) { + const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError) + ctx.logger.warn( + `context-overflow compaction failed: ${message}; preserving the original request error`, + ) + return next() + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited. + if (signal.aborted || result === null + || agent.session.surface.replaceGeneration <= generation) return next() + logResult(result, 'context overflow recovery') + return { action: 'retry' } + }) } /** @@ -96,7 +126,7 @@ export class BasicCompactService extends CompactService { * @param signal - optional cancellation forwarded to the adapter. * @returns safe text summary blocks and exact auxiliary-call provenance. */ - async summarize( + protected async summarize( text: string, agent: Agent, signal?: AbortSignal, @@ -105,27 +135,39 @@ export class BasicCompactService extends CompactService { } /** - * Check replayed pressure for the provisional pre-step envelope and compact - * a tool-balanced head until it falls below the service-wide threshold. - * A genuinely model-less router-first step skips this provisional check. - * @param agent - agent whose session and provisional provider/model are measured. - * @param fullSystemPrompt - current assembled system prompt override. - * @param sessionPrefix - current request-only prefix override. - * @param signal - live step cancellation signal forwarded to summarization. + * Compact for replayed post-step pressure or one provider-confirmed context + * overflow. Both triggers price the latest durable routed request envelope; + * overflow bypasses the normal threshold and retained-tail policy so it can + * force one useful balanced reduction. + * @param agent - agent whose latest durable routed request is measured. + * @param trigger - normal post-step pressure or context-overflow recovery. + * @param signal - live turn cancellation signal forwarded to summarization. * @returns the latest compaction result, or `null` when no check/work applies. */ override async compactIfNeeded( agent: Agent, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], + trigger: CompactionTrigger, signal: AbortSignal, ): Promise { - const target = effectiveTarget(agent) - if (target === undefined) return null + const model = routedModel(agent.session) + if (model === undefined) return null const meter = this.ctx.tokenMeter - const requestHeader = provisionalHeader(target, agent.session, fullSystemPrompt, sessionPrefix) + switch (trigger) { + case 'context-overflow': { + const measurement = meter.measure(agent.session) + const range = selectCompactableRange(agent.session, measurement, 0) + if (range === null) return null + return this.compactRegion(range.start, range.end, agent, signal) + } + case 'pressure': + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(trigger, 'compaction trigger') + } + const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) - let measurement = meter.measure(agent.session, requestHeader) + let measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return null let result: CompactionResult | null = null @@ -137,8 +179,8 @@ export class BasicCompactService extends CompactService { /* v8 ignore next -- paired with the defensive post-success branch above. */ break } - result = await this.compactRegion(agent.session, range.start, range.end, agent, signal) - measurement = meter.measure(agent.session, requestHeader) + result = await this.compactRegion(range.start, range.end, agent, signal) + measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return result } @@ -149,10 +191,8 @@ export class BasicCompactService extends CompactService { } /** - * Compact one inclusive positional surface range using the effective - * token meter for all retention and shrink pricing. Reject an agent that does - * not own the exact target before any mutation. - * @param session - session whose surface is mutated; must equal `agent.session`. + * Compact one inclusive positional range from the agent-owned surface using + * the effective token meter for all retention and shrink pricing. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. * @param agent - owner of the target session, used by the summarizer. @@ -160,15 +200,12 @@ export class BasicCompactService extends CompactService { * @returns the successful durable compaction result. */ override async compactRegion( - session: Session, start: number, end: number, agent: Agent, signal?: AbortSignal, ): Promise { - if (session !== agent.session) { - throw new Error('compactRegion: agent.session must be the exact target session') - } + const session = agent.session return compactSurfaceRegion({ meter: this.ctx.tokenMeter, summarize: (text, owner, abort) => this.summarize(text, owner, abort), diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index c814c7ecc8..62b5f5f5e2 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -119,8 +119,8 @@ export async function summarizeWithLlm( } return { summary, - provider: target.provider, - model: target.model, + provider: options.provider, + model: options.model, maxTokens: config.maxTokens, } } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 8145ce164e..6ed0165226 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -18,7 +18,9 @@ export interface BasicCompactConfig { maxTokens?: number /** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */ compactionRetries?: number - /** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */ + /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ + maxOverflowRetries?: number + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } @@ -30,5 +32,6 @@ export interface ResolvedConfig { readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number + readonly maxOverflowRetries: number readonly auto: boolean } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 2d315293ed..0a411440b3 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,11 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic' +import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -33,6 +35,12 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) + if (turn === 1) { + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL } }, + reason: 'initial', + }) + } session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn, @@ -59,6 +67,12 @@ function toolConversation(): Session { source: { kind: 'user' }, }, { surfaceOp: 'append' }) session.append('step/start', { turn, step: 1 }) + if (turn === 1) { + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL } }, + reason: 'initial', + }) + } session.append('assistant/message', { provenance: { provider: MODEL, model: MODEL }, turn, @@ -118,11 +132,10 @@ function service( async function compactIfNeeded( compact: BasicCompactService, session: Session, + trigger: 'pressure' | 'context-overflow' = 'pressure', model: string | undefined = MODEL, - system = '', - prefix: readonly Message[] = [], ): Promise { - return compact.compactIfNeeded(agent(session, model), system, prefix, SIGNAL) + return compact.compactIfNeeded(agent(session, model), trigger, SIGNAL) } describe('compact configuration and defaults', () => { @@ -137,6 +150,7 @@ describe('compact configuration and defaults', () => { summarizationModel: '', maxTokens: 8192, compactionRetries: 1, + maxOverflowRetries: 1, auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) @@ -166,6 +180,7 @@ describe('compact configuration and defaults', () => { const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], + [{ maxOverflowRetries: -1 }, /maxOverflowRetries/], [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], @@ -192,19 +207,58 @@ describe('pressure measurement and retention', () => { retainTokens: 180, } - it('skips the provisional check only when no routed or fallback model exists', async () => { + it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => { const compact = service(compactConfig) - const session = conversation() - expect(await compact.compactIfNeeded(agent(session), '', [], SIGNAL)).toBeNull() + const session = new Session(SessionId('headerless')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL)) + .resolves.toBeNull() expect(compact.calls).toHaveLength(0) }) it('meters any routed model without profile resolution', async () => { const compact = service(compactConfig) - await expect(compactIfNeeded(compact, conversation(), 'unlisted-model')) + const session = conversation() + session.append('request/header', { + header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } }, + reason: 'resume', + }) + await expect(compactIfNeeded(compact, session)) .resolves.not.toBeNull() }) + it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { + const compact = service(compactConfig) + const session = new Session(SessionId('single-tool-pair')) + const callId = CallId('single-call') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL } }, + reason: 'initial', + }) + session.append('assistant/message', { + provenance: { provider: MODEL, model: MODEL }, + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' }) + session.append('tool/result', { + turn: 1, + step: 1, + callId, + content: [{ type: 'text', text: 'result' }], + isError: false, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + const generation = session.surface.replaceGeneration + + await expect(compactIfNeeded(compact, session, 'context-overflow')).resolves.toBeNull() + expect(session.surface.replaceGeneration).toBe(generation) + expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + }) + it('does nothing below threshold and compacts a priced head above threshold', async () => { const compact = service(compactConfig) expect(await compactIfNeeded(compact, conversation(2))).toBeNull() @@ -216,26 +270,31 @@ describe('pressure measurement and retention', () => { expect(session.surface.nodes.length).toBeLessThan(8) }) - it('counts the current prompt and request prefix without putting either on the surface', async () => { + it('counts the durable routed request envelope without putting its prefix on the surface', async () => { const compact = service({ auto: false, - thresholdRatio: 0.7, + thresholdRatio: 0.9, retainTokens: 50, }) - const session = conversation(2, 'x'.repeat(200)) + const session = conversation(2, 'x'.repeat(600)) expect(await compactIfNeeded(compact, session)).toBeNull() - const prefix: Message[] = [{ - role: 'user', - content: [{ type: 'text', text: 'p'.repeat(1_000) }], - }] - const result = await compactIfNeeded(compact, session, MODEL, 's'.repeat(1_000), prefix) + const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }] + session.append('request/header', { + header: { + config: { provider: MODEL, model: MODEL }, + system: 's'.repeat(600), + messagePrefix: prefix, + }, + reason: 'resume', + }) + const result = await compactIfNeeded(compact, session) expect(result).not.toBeNull() expect(prefix).toHaveLength(1) expect(session.events.some(event => event.type === 'context/message')).toBe(false) }) - it('uses the latest logged routed model in the provisional request envelope', async () => { + it('uses the latest logged request envelope without an AgentOptions override', async () => { const ctx = createContext() const compact = service({ auto: false, @@ -249,19 +308,28 @@ describe('pressure measurement and retention', () => { }) const measure = vi.spyOn(ctx.tokenMeter, 'measure') - const result = await compactIfNeeded(compact, session, 'fallback') + const result = await compactIfNeeded(compact, session, 'pressure', 'fallback') expect(result).not.toBeNull() - expect(measure.mock.calls[0]?.[1]?.config.provider).toBe('actual') - expect(measure.mock.calls[0]?.[1]?.config.model).toBe('actual') + expect(session.requestHeader()?.config.model).toBe('actual') + expect(measure.mock.calls[0]).toEqual([session]) }) it('declines when envelope pressure is high but the surface has no compactable range', async () => { const compact = service(compactConfig) const empty = new Session(SessionId('empty')) - expect(await compactIfNeeded(compact, empty, MODEL, 'x'.repeat(100_000))).toBeNull() + empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + empty.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, + reason: 'initial', + }) + expect(await compactIfNeeded(compact, empty)).toBeNull() const retained = conversation(1) - expect(await compactIfNeeded(compact, retained, MODEL, 'x'.repeat(100_000))).toBeNull() + retained.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) }, + reason: 'resume', + }) + expect(await compactIfNeeded(compact, retained)).toBeNull() }) it('uses one unified measurement for each pressure-and-retention decision', async () => { @@ -349,32 +417,11 @@ describe('pressure measurement and retention', () => { }) describe('compaction region transaction', () => { - it('rejects an agent that does not own the exact target session before mutation', async () => { - const compact = service() - const target = conversation(2) - const owner = conversation(1) - const targetEvents = [...target.events] - const ownerEvents = [...owner.events] - const nodes = target.surface.nodes - - await expect(compact.compactRegion( - target, - nodes[0]!, - nodes[1]!, - agent(owner), - )).rejects.toThrow('compactRegion: agent.session must be the exact target session') - - expect(target.events).toEqual(targetEvents) - expect(owner.events).toEqual(ownerEvents) - expect(compact.calls).toEqual([]) - }) - it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { const compact = service() const session = conversation(3) - const before = session.surface.nodes + const before = [...session.surface.nodes] const result = await compact.compactRegion( - session, before[0]!, before[3]!, agent(session, MODEL), @@ -410,7 +457,6 @@ describe('compaction region transaction', () => { const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, startOverride ?? nodes[0]!, endOverride ?? nodes[1]!, agent(session, MODEL), @@ -422,7 +468,6 @@ describe('compaction region transaction', () => { const plain = conversation(2) const nodes = plain.surface.nodes await expect(compact.compactRegion( - plain, nodes[2]!, nodes[1]!, agent(plain, MODEL), @@ -431,13 +476,11 @@ describe('compaction region transaction', () => { const tools = toolConversation() const toolNodes = tools.surface.nodes await expect(compact.compactRegion( - tools, toolNodes[2]!, toolNodes[4]!, agent(tools, MODEL), )).rejects.toThrow(/start seq .* not a balanced boundary/) await expect(compact.compactRegion( - tools, toolNodes[0]!, toolNodes[1]!, agent(tools, MODEL), @@ -450,7 +493,6 @@ describe('compaction region transaction', () => { closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) const nodes = closed.surface.nodes await expect(compact.compactRegion( - closed, nodes[0]!, nodes[1]!, agent(closed, MODEL), @@ -460,7 +502,6 @@ describe('compaction region transaction', () => { locked.append('compact/start', { turn: 2 }) const lockedNodes = locked.surface.nodes await expect(compact.compactRegion( - locked, lockedNodes[0]!, lockedNodes[1]!, agent(locked, MODEL), @@ -477,7 +518,6 @@ describe('compaction region transaction', () => { const node = session.surface.nodes[0]! await expect(compact.compactRegion( - session, node, node, agent(session, MODEL), @@ -497,7 +537,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -511,7 +550,6 @@ describe('compaction region transaction', () => { const before = session.surface.nodes await expect(compact.compactRegion( - session, before[0]!, before[2]!, agent(session, MODEL), @@ -527,7 +565,6 @@ describe('compaction region transaction', () => { const session = conversation(2) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -548,7 +585,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -566,7 +602,6 @@ describe('compaction region transaction', () => { const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[2]!, agent(session, MODEL), @@ -576,10 +611,22 @@ describe('compaction region transaction', () => { it('lets a model-independent custom summarizer compact without a conversation model', async () => { const compact = service() - const session = conversation(1) + const session = new Session(SessionId('model-less-region')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: 'history '.repeat(100) }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + provenance: { provider: 'historical', model: 'historical' }, + turn: 1, + step: 1, + content: [{ type: 'text', text: 'answer '.repeat(100) }], + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) const nodes = session.surface.nodes await expect(compact.compactRegion( - session, nodes[0]!, nodes[1]!, agent(session), @@ -613,18 +660,28 @@ class ScriptedAdapter extends LlmAdapter { } } +class ExposedCompactService extends BasicCompactService { + runSummarize( + text: string, + owner: Agent, + signal?: AbortSignal, + ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { + return this.summarize(text, owner, signal) + } +} + async function summarizerHarness( blocks: readonly ContentBlock[], finish?: (StreamChunk & { type: 'finish' })['reason'], model = MODEL, config: BasicCompactConfig = { auto: false }, -): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> { +): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx, { contextWindow: 1_000 }) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) - const compact = new BasicCompactService(ctx, config) + const compact = new ExposedCompactService(ctx, config) return { ctx, adapter, compact } } @@ -641,7 +698,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, }) const session = conversation(1) - const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL) + const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], @@ -666,19 +723,41 @@ describe('default one-shot summarizer', () => { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) - const output = await compact.summarize('history', agent(session, 'fallback')) + const output = await compact.runSummarize('history', agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') expect(adapter.lastOptions?.model).toBe('routed') }) + it('records the model actually dispatched after one-shot stream routing', async () => { + const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) + const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }]) + ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter) + ctx.on('llm/stream', (options, next) => { + options.provider = 'routed-summary-provider' + options.model = 'routed-summary-model' + return next() + }) + + const session = conversation(3, 'large history '.repeat(500)) + const nodes = session.surface.nodes + await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) + expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({ + summary: [{ type: 'text', text: 'routed summary' }], + provider: 'routed-summary-provider', + model: 'routed-summary-model', + }) + expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider') + expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model') + }) + it('fails clearly when no complete summarization target can be resolved', async () => { const ctx = new Context() await ctx.plugin(LlmService) void new TokenMeterService(ctx) - const compact = new BasicCompactService(ctx, { auto: false }) - await expect(compact.summarize('history', agent(new Session(SessionId('model-less'))))) + const compact = new ExposedCompactService(ctx, { auto: false }) + await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -693,7 +772,7 @@ describe('default one-shot summarizer', () => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { - await compact.summarize('history', agent(conversation(1), MODEL)) + await compact.runSummarize('history', agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } @@ -705,32 +784,63 @@ describe('default one-shot summarizer', () => { it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) - await expect(compact.summarize('history', agent(conversation(1), MODEL))) + await expect(compact.runSummarize('history', agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) describe('automatic listener and loader composition', () => { - function preStep(ctx: Context, owner: Agent): Promise { - return ctx.serial('agent/pre-step', owner, 1, 1, '', [], SIGNAL) + function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise { + return ctx.serial('agent/post-step', owner, 1, 1, signal) } - it('compacts above threshold and remains idle below it', async () => { + function recover( + ctx: Context, + owner: Agent, + error: Error & { code?: string }, + retryAttempt = 0, + signal = SIGNAL, + next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }), + ): Promise<{ action: 'fail' | 'retry' }> { + return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next) + } + + function overflow(message = 'provider overflow'): Error & { code: string } { + return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE }) + } + + it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { thresholdRatio: 0.5, retainTokens: 180, }) const pressured = conversation(4) - await preStep(ctx, agent(pressured, MODEL)) + await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback')) expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true) const small = conversation(1) - await preStep(ctx, agent(small, MODEL)) + await postStep(ctx, agent(small, MODEL)) expect(small.events.some(event => event.type === 'compact/start')).toBe(false) expect(compact.calls).toHaveLength(1) }) + it('skips post-step pressure when the step signal is already aborted', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 180, + }) + const pressured = conversation(4) + const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded') + + await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted'))) + .resolves.toBeUndefined() + + expect(compactIfNeeded).not.toHaveBeenCalled() + expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false) + }) + it('warns and continues after operational failures, including non-Errors', async () => { const ctx = createContext() const warnings: string[] = [] @@ -742,12 +852,187 @@ describe('automatic listener and loader composition', () => { compact.error = 'temporary failure' const session = conversation(4) - await expect(preStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() + await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined() expect(warnings).toContainEqual(expect.stringContaining('temporary failure')) expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) - it('auto:false installs no listener', async () => { + it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { + const ctx = createContext(10_000) + void new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 900, + }) + const session = conversation(3) + const beforeGeneration = session.surface.replaceGeneration + const retainedSeq = session.surface.nodes.at(-1)! + const threshold = 10_000 + expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold) + const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow()) + + expect(decision).toEqual({ action: 'retry' }) + expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1) + expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(session.surface.nodes).toContain(retainedSeq) + }) + + it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + thresholdRatio: 1, + retainTokens: 90, + }) + const session = toolConversation() + const newestAssistant = session.surface.nodes.at(-2)! + const newestResult = session.surface.nodes.at(-1)! + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) + const currentAssistant = session.surface.nodes.find(node => node === newestAssistant) + const currentResult = session.surface.nodes.find(node => node === newestResult) + expect(currentAssistant).toBeDefined() + expect(currentResult).toBeDefined() + expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true) + expect(toolPairingBalancedAfter(session, currentResult!)).toBe(true) + }) + + it('does not retry when a backend reports success without replacing the surface', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + const session = conversation(2) + const fakeResult: CompactionResult = { + startSeq: 1, + summarySeq: 2, + endSeq: 3, + summary: [{ type: 'text', text: 'fake' }], + shadowedRange: { start: 1, end: 2 }, + shadowedSeqs: [1, 2], + shadowedTokenCount: 10, + } + vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult) + + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(session.surface.replaceGeneration).toBe(0) + }) + + it('delegates downstream exactly once when no replacement is available', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(null) + const downstream = new Error('downstream recovery failed') + let calls = 0 + + await expect(recover( + ctx, + agent(conversation(2), MODEL), + overflow(), + 0, + SIGNAL, + () => { + calls += 1 + return Promise.reject(downstream) + }, + )).rejects.toBe(downstream) + expect(calls).toBe(1) + }) + + it('preserves the original provider error when recovery throws', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + const compact = new TestCompactService(ctx) + compact.error = new Error('summary unavailable') + const original = overflow('original provider overflow') + + expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' }) + expect(original).toMatchObject({ + message: 'original provider overflow', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + expect(warnings).toContainEqual(expect.stringContaining('preserving the original request error')) + }) + + it('delegates once when overflow recovery throws a non-Error value', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + const compact = new TestCompactService(ctx) + compact.error = 'non-error recovery failure' + const session = conversation(3) + const generation = session.surface.replaceGeneration + const original = overflow('original provider failure') + let delegations = 0 + + const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => { + delegations += 1 + return Promise.resolve({ action: 'fail' }) + }) + + expect(decision).toEqual({ action: 'fail' }) + expect(delegations).toBe(1) + expect(session.surface.replaceGeneration).toBe(generation) + expect(original).toMatchObject({ + message: 'original provider failure', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure')) + }) + + it('recovers an overflow for an unlisted routed model', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + const session = conversation(2) + session.append('request/header', { + header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } }, + reason: 'resume', + }) + expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow'))) + .toEqual({ action: 'retry' }) + }) + + it('honors retry caps, non-context failures, and cancellation', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) + const compactSpy = vi.spyOn(compact, 'compactIfNeeded') + const owner = agent(conversation(3), MODEL) + expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' }))) + .toEqual({ action: 'fail' }) + expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' }) + + const controller = new AbortController() + controller.abort('cancelled') + expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' }) + expect(compactSpy).not.toHaveBeenCalled() + }) + + it('does not retry when cancellation lands during an awaited compaction', async () => { + const ctx = createContext() + const compact = new TestCompactService(ctx) + const controller = new AbortController() + compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') } + const session = conversation(3) + const generation = session.surface.replaceGeneration + + expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal)) + .toEqual({ action: 'fail' }) + expect(session.surface.replaceGeneration).toBe(generation + 1) + }) + + it('maxOverflowRetries:0 disables recovery without disabling post-step pressure', async () => { + const ctx = createContext() + void new TestCompactService(ctx, { + maxOverflowRetries: 0, + thresholdRatio: 0.5, + retainTokens: 180, + }) + const session = conversation(4) + await postStep(ctx, agent(session, MODEL)) + const summaries = session.events.filter(event => event.type === 'compact/summary').length + expect(summaries).toBe(1) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) + expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries) + }) + + it('auto:false installs neither automatic listener', async () => { const ctx = createContext() void new TestCompactService(ctx, { auto: false, @@ -755,8 +1040,9 @@ describe('automatic listener and loader composition', () => { retainTokens: 180, }) const session = conversation(4) - await preStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) it('loads and disposes the real zero-config service stack', async () => { @@ -784,7 +1070,8 @@ describe('automatic listener and loader composition', () => { await fiber.dispose() const session = conversation(4) - await preStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) expect(session.events.some(event => event.type === 'compact/start')).toBe(false) + expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' }) }) }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index d163d84804..1327f073c5 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,16 +1,17 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' +import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { SurfaceEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session' /** * CBR-001 regression through the real loop. A replacement checkpoint has a high @@ -55,6 +56,45 @@ class StepwiseToolAdapter extends LlmAdapter { } } +/** First conversation request overflows, then the rebuilt retry succeeds. */ +class OverflowRecoveryAdapter extends LlmAdapter { + readonly conversationRequests: GenerateOptions[] = [] + readonly summaryRequests: GenerateOptions[] = [] + + constructor(private readonly delivery: 'thrown' | 'in-band') { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + if (options.system?.includes('You are a compaction engine')) { + this.summaryRequests.push(options) + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + + this.conversationRequests.push(options) + if (this.conversationRequests.length === 1) { + if (this.delivery === 'thrown') { + throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE) + } + yield { + type: 'finish', + reason: { + kind: 'error', + message: 'request too large for model context', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }, + } + return + } + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -83,7 +123,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr return { ctx, compact } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -95,10 +135,58 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { + it('uses the model actually routed by agent/request for post-step pressure', async () => { + const { ctx } = await harness(8) + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) + try { + const agent = ctx.agentLoop.create(SessionId('routed-pressure'), { + provider: 'unconfigured-agent-fallback', + model: 'unconfigured-agent-fallback', + }) + agent.send([{ type: 'text', text: 'do a routed multi-step task' }]) + await waitForIdle(ctx, agent) + + expect(agent.session.requestHeader()?.config.model).toBe('mock') + expect(agent.session.events.some(event => event.type === 'compact/summary')).toBe(true) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }) + + it('runs automatic pressure after the current tool result and before step/end', async () => { + const { ctx } = await harness(8) + try { + const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'do tool work' }]) + await waitForIdle(ctx, agent) + + const events = [...agent.session.events] + const compactStart = events.find(event => event.type === 'compact/start') + expect(compactStart).toBeDefined() + const precedingResult = events.findLast(event => + event.type === 'tool/result' && event.seq < compactStart!.seq, + ) + if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction') + const stepEnd = events.find(event => + event.type === 'step/end' + && event.data.step === precedingResult.data.step + && event.seq > compactStart!.seq, + ) + expect(precedingResult.seq).toBeLessThan(compactStart!.seq) + expect(compactStart!.seq).toBeLessThan(stepEnd!.seq) + } finally { + await ctx.fiber.dispose() + } + }) + it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(AgentId('repro'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) @@ -127,3 +215,88 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () } }) }) + +describe('context-overflow recovery across the real loop and compact-basic', () => { + it.each(['thrown', 'in-band'] as const)( + 'force-compacts a %s overflow between failed and retry steps', + async (delivery) => { + const ctx = new Context() + const adapter = new OverflowRecoveryAdapter(delivery) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) + await ctx.plugin(BasicCompactService, { + thresholdRatio: 1, + retainTokens: 100, + maxTokens: 64, + compactionRetries: 0, + maxOverflowRetries: 1, + }) + + try { + const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), { + provider: 'unconfigured-agent-fallback', + model: 'unconfigured-agent-fallback', + }) + for (let turn = 1; turn <= 2; turn += 1) { + const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY' + agent.session.append('turn/start', { + turn, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + agent.session.append('user/message', { + content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + agent.session.append('step/start', { turn, step: 1 }) + agent.session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn, + step: 1, + content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }], + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn, step: 1 }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + agent.send([{ type: 'text', text: 'continue from history' }]) + await agent.whenIdle() + + expect(adapter.conversationRequests).toHaveLength(2) + expect(adapter.summaryRequests).toHaveLength(1) + expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL') + const retry = JSON.stringify(adapter.conversationRequests[1]!.messages) + expect(retry).toContain('RECOVERY CHECKPOINT') + expect(retry).not.toContain('OLD HISTORY SENTINEL') + + const events = [...agent.session.events] + const failedEnd = events.find(event => + event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1, + )! + const retryStart = events.find(event => + event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2, + )! + const compaction = events.filter(event => + event.type === 'compact/start' + || event.type === 'compact/summary' + || event.type === 'compact/end', + ) + expect(compaction.map(event => event.type)).toEqual([ + 'compact/start', + 'compact/summary', + 'compact/end', + ]) + expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true) + expect(events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await ctx.fiber.dispose() + } + }, + ) +}) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index ec14947090..f0af3c6e74 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,8 +18,10 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev | Member | Semantics | |---|---| -| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | -| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | +| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | + +`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult). `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. @@ -59,19 +61,34 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and ### Conversation history, when a backend is invoked -**What the model sees**: A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. +#### What the model sees -**Token effect**: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged. +A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. + +#### Token effect + +Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged. + +#### KV Cache effect + +A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request. ### Transcript supplied to a compaction consumer -**What the model sees**: `renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. +#### What the model sees -**Token effect**: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. +`renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. + +#### Token effect + +Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. + +#### KV Cache effect + +No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference. ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. -- **Single-unit overflow is out of contract** — one retained unit (a closed step or a large pasted `user/message`) alone exceeding the budget cannot be compacted; the call may go out over-budget. -- **A session prefix that alone approaches the window is a configuration error no backend fixes** — compaction shrinks derived history, never the prefix. -- **Request context injected by downstream `agent/request` listeners sits outside pressure accounting** — `compactIfNeeded` counts prefix, derived history, and system prompt only. +- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted. +- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7361d38ef3..7f0844c9f9 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -8,7 +8,6 @@ */ import { Context, Service } from 'cordis' -import type { Message } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' @@ -16,10 +15,13 @@ export type { CompactionResult } from './types.ts' export { renderContentBlocks, renderTranscript } from './render.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' +/** Why automatic policy is asking a backend to consider compaction. */ +export type CompactionTrigger = 'pressure' | 'context-overflow' + /** Minimal agent context compaction needs without depending on the agent package. */ export interface CompactAgentContext { session: Session - options: { model?: string } + options: { provider?: string; model?: string } } declare module 'cordis' { @@ -41,24 +43,20 @@ export abstract class CompactService extends Service { } /** - * Check token pressure and compact if the conversation is too large. - * Estimate the next request, including its session prefix, derived history, - * and system prompt. Above threshold, compact a head-anchored range ending at - * a balanced tool boundary and reconsolidate any prior automatic checkpoint. - * Return `null` when no compaction is needed or an open tail leaves no safe - * cutoff. A single oversized retained unit or prefix cannot be repaired here. + * Consider automatic compaction for one explicit trigger. Pressure policy + * uses the latest durable routed request, while context-overflow policy may + * force a useful balanced reduction even below the normal threshold. Return + * `null` when no safe range can be compacted. A single oversized retained + * unit or request envelope cannot be repaired through surface compaction. * - * @param agent - agent context owning the session surface and model options. - * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. - * @param sessionPrefix - the instance's composed session prefix, counted toward the - * estimate. + * @param agent - agent context owning the session surface and routing options. + * @param trigger - normal pressure or provider-confirmed context overflow. * @param signal - cancellation signal; model-backed implementations must forward it. * @returns the compaction result, or `null` if no compaction was needed. */ abstract compactIfNeeded( agent: CompactAgentContext, - fullSystemPrompt: string, - sessionPrefix: readonly Message[], + trigger: CompactionTrigger, signal: AbortSignal, ): Promise @@ -67,23 +65,19 @@ export abstract class CompactService extends Service { * `start` and `end` name an inclusive span by surface position, not numeric seq * order; replacements can make visible seqs non-monotonic. Both edges must be * balanced so assistant tool calls remain paired with their results. A model- - * backed implementation forwards cancellation. The agent must own the exact - * target session object; implementations reject an ownership mismatch before - * model resolution, lock acquisition, summarization, or log mutation, and - * reject active, missing, reversed, or unbalanced ranges. + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} * for the edge checks. * - * @param session - session to mutate; must be identical to `agent.session`. * @param start - first surface seq, inclusive. * @param end - last surface seq, inclusive. - * @param agent - owner of the target session and summarizer context. + * @param agent - context whose session is mutated and whose routing options guide summarization. * @param signal - optional cancellation; model-backed implementations must forward it. - * @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced. - * @returns the replaced range and summary. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. */ abstract compactRegion( - session: Session, start: number, end: number, agent: CompactAgentContext, diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index f272b1fe85..559d46bdc9 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CompactService } from '@deepseek-ai/dsh-compact' -import type { CompactionResult } from '@deepseek-ai/dsh-compact' -import type { Message } from '@deepseek-ai/dsh-llm' +import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { CompactAgentContext } from '@deepseek-ai/dsh-compact' @@ -18,8 +17,7 @@ class StubCompactService extends CompactService { override async compactIfNeeded( _agent: CompactAgentContext, - _fullSystemPrompt: string, - _sessionPrefix: readonly Message[], + _trigger: CompactionTrigger, signal: AbortSignal, ): Promise { this.lastSignal = signal @@ -27,17 +25,18 @@ class StubCompactService extends CompactService { } override async compactRegion( - session: Session, start: number, end: number, - _agent: CompactAgentContext, + agent: CompactAgentContext, signal?: AbortSignal, ): Promise { this.lastSignal = signal + const session = agent.session + const summary = [{ type: 'text' as const, text: 'stub' }] // Minimal stub honoring the lock + log-only event contract. const startEvent = session.append('compact/start', { turn: 0 }) const summaryEvent = session.append('compact/summary', { - summary: [{ type: 'text', text: 'stub' }], + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -49,7 +48,7 @@ class StubCompactService extends CompactService { startSeq: startEvent.seq, summarySeq: summaryEvent.seq, endSeq: endEvent.seq, - summary: [{ type: 'text', text: 'stub' }], + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -81,7 +80,7 @@ describe('CompactService seam', () => { const ctx = new Context() const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull() + expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull() }) it('compact/* events merge into SessionEventMap and are log-only', async () => { @@ -89,7 +88,7 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm')) + const result = await svc.compactRegion(0, 0, stubAgent(session, 'm')) const startEvent = session.events.find(e => e.type === 'compact/start') expect(startEvent).toBeDefined() @@ -97,8 +96,12 @@ describe('CompactService seam', () => { // verify the runtime value is absent. const raw = startEvent as unknown as { surfaceOp?: unknown } expect(raw.surfaceOp).toBeUndefined() + expect(result.summary).toEqual([{ type: 'text', text: 'stub' }]) expect(result.summarySeq).toBeGreaterThan(result.startSeq) expect(result.endSeq).toBeGreaterThan(result.summarySeq) + expect(result.shadowedRange).toEqual({ start: 0, end: 0 }) + expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) + .toEqual(['compact/start', 'compact/summary', 'compact/end']) }) it('threads the cancellation signal through to the backend', async () => { @@ -107,10 +110,10 @@ describe('CompactService seam', () => { const session = new Session(SessionId('s')) const controller = new AbortController() - await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal) + await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal) expect(svc.lastSignal).toBe(controller.signal) - await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal) + await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal) expect(svc.lastSignal).toBe(controller.signal) }) }) diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index fea92e726f..f29f908acb 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -32,24 +32,32 @@ The time reading stays in derived conversation history until a later compaction ### Preparation-time temporal context -**What the model sees**: On each preparation attempt that injects, one source-tagged context message containing the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading. +#### What the model sees -**Token effect**: Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt. +On each preparation attempt that injects, one source-tagged context message containing the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. Positive intervals can leave an attempted step without a new reading. -#### First step +##### First step ```markdown Time sampled while preparing turn , step 1: Elapsed since the preceding model-visible message: . ``` -#### Later steps +##### Later steps ```markdown Time sampled while preparing turn , step : Elapsed since the preceding step context: . ``` +#### Token effect + +Each injected two-line message accumulates until compaction shadows it. A positive interval reduces additions; omission or `0` adds one for every eligible preparation attempt. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds. diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index f8463e4bec..96ea7165af 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -8,7 +8,6 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { Message } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' @@ -162,8 +161,6 @@ export function apply(ctx: Context, config: Config): void { agent: Agent, turn: number, step: number, - _fullSystemPrompt: string, - _sessionPrefix: readonly Message[], signal: AbortSignal, ) => { if (signal.aborted) return diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 2796e8230c..06ae13818d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -4,7 +4,7 @@ import Loader from '@cordisjs/plugin-loader' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -38,7 +38,7 @@ async function mount(config: Config = {}) { function sessionAgent(session: Session, id = 'agent'): Agent { return { - id: AgentId(id), + id: SessionId(id), options: {}, session, status: 'running', @@ -83,7 +83,7 @@ async function fire( step: number, signal: AbortSignal = SIGNAL, ): Promise { - await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal) + await ctx.serial('agent/pre-step', agent, turn, step, signal) } function textResponse(text: string): StreamChunk[] { @@ -370,7 +370,7 @@ describe('real agent-loop request history', () => { if (mode === 'throws') throw new Error('later pre-step failure') subject.cancel('later pre-step cancellation') }) - const agent = ctx.agentLoop.create(AgentId(`late-${mode}`), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId(`late-${mode}`), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() @@ -396,7 +396,7 @@ describe('real agent-loop request history', () => { return [{ type: 'text' as const, text: 'advanced' }] }, })) - const agent = ctx.agentLoop.create(AgentId('loop'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('loop'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() diff --git a/packages/context/workspace-context/README.md b/packages/context/workspace-context/README.md index 430ffe8a41..a04c844e9b 100644 --- a/packages/context/workspace-context/README.md +++ b/packages/context/workspace-context/README.md @@ -78,11 +78,11 @@ Instruction content is read through `streamText()` under `maxSourceBytes`, even ### Baseline session prefix -**What the model sees**: At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order. +#### What the model sees -**Token effect**: The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. +At the first request of each loop instance, the model receives one user-role prefix message containing the bounded user-global and project instruction chain in broad-to-specific order. -#### Baseline instruction template +##### Baseline instruction template ```markdown @@ -98,13 +98,21 @@ Instructions from: AGENTS.md ``` +#### Token effect + +The rendered baseline is frozen and resent on every request in that loop instance. `maxBytes` bounds the complete message, broader files are omitted before the most-specific file is truncated, and an empty chain contributes zero tokens. + +#### KV Cache effect + +Prefix-stable within one loop instance because the baseline is frozen. A new or resumed instance recomposes it, so instruction, precedence, cwd, candidate, or byte-budget changes may invalidate reuse from the first changed baseline token. + ### Newly discovered scope context -**What the model sees**: After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. +#### What the model sees -**Token effect**: Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result. +After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file. -#### Additional instruction template +##### Additional instruction template ```markdown @@ -116,13 +124,21 @@ These instructions apply to work under `packages/app`. Use them as guidance when ``` +#### Token effect + +Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Changed or removed instruction context -**What the model sees**: A changed file produces `Updated instructions from: ` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below. +#### What the model sees -**Token effect**: Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch. +A changed file produces `Updated instructions from: ` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below. -#### Removal notice +##### Removal notice ```markdown @@ -132,6 +148,14 @@ The previously loaded instructions from this file no longer apply. ``` +#### Token effect + +Each confirmed change or removal is one retained history message bounded by `maxBytes`. Provider failures add no message, and an update omitted by the budget remains eligible for a later filesystem touch. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam. diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index eab9603fa5..0c11da8ed4 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -7,7 +7,7 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -46,7 +46,6 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) const handle = await ctx.agents.create({ - agentId: AgentId('workspace-context-e2e'), sessionId: SessionId('workspace-context-e2e-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 2894937c9c..2ff2067cf5 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -7,7 +7,7 @@ import Loader from '@cordisjs/plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, type Agent, type HookContext } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -163,7 +163,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { const session = new Session(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd }) return { ctx: new Context(), - id: AgentId('a1'), + id: SessionId('a1'), options: {}, session, status: 'idle', @@ -1591,7 +1591,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) + const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root }) ctx.tools.register(defineTool({ name: 'abort_step', description: 'Abort the current test step.', diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index fdc4e51e6b..a8dc203342 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -4,7 +4,7 @@ The self-referential cordis toolset: three model-facing tools over the live runt ## What it does -- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. +- `cordis_inspect` — read-only report over the runtime: services, the loaded-plugin list, registered tools, the dynamic-mount table, and the catalog-backed `api` / `events` references. An exact `name` with `what: "api"` or `what: "events"` narrows the report and adds the original source JSDoc. - `cordis_mount` — evaluates model-written JavaScript (the body of an async function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted under the `cordis-dynamic` group fiber and tracked as `dyn-`. - `cordis_unmount` — disposes one mount by id, returning only after quiescence. @@ -22,7 +22,7 @@ The sandbox isolates globals but is not a security boundary. Node globals are ab ## The generated API catalog -`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. +`src/api-catalog.ts` is generated by `scripts/gen-cordis-api.ts` from the same AST walk as [docs/cordis-catalog](../../../docs/cordis-catalog/services.md) and freshness-gated by `pnpm run verify-cordis-api` (in `doc-sync`) — never edit it by hand. `cordis_inspect` intersects it with the live service store at call time. Broad `api` / `events` reports render summaries and signatures only; an exact `name` opts into the retained method/event JSDoc, and unknown or non-running service targets fail loud. ## Rendering @@ -36,21 +36,45 @@ Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no defau ### Tool schemas -**What the model sees**: The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. +#### What the model sees -**Token effect**: Fixed schema cost on every request in that tool view. +The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. + +#### Token effect + +Fixed schema cost on every request in that tool view. + +#### KV Cache effect + +Prefix-stable while this tool view is unchanged. Scoping or plugin lifecycle changes that hide these definitions may invalidate reuse from the first changed schema token. ### Tool-call history and results -**What the model sees**: Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. +#### What the model sees -**Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small. +Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Its broad API/event reports omit JSDoc; `name` with `what: "api"` or `what: "events"` returns one exact target with its original JSDoc. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. + +#### Token effect + +Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Later requests after a mount -**What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. +#### What the model sees -**Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. +A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. + +#### Token effect + +Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. + +#### KV Cache effect + +Mounting or unmounting a prompt or tool contribution changes later request prefixes and may invalidate reuse from the first changed contribution; an unchanged mount set remains prefix-stable. ## Known Limitations and Deferred Work diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b49cb54026..52f8477318 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -4,22 +4,30 @@ * `pnpm run verify-cordis-api` in doc-sync). * * The machine-readable cordis API catalog `cordis_inspect` serves to the - * model: harness services (summary + public method signatures), harness - * events (mode + signature), and the inherited `ctx` surface. Produced by + * model: harness services (summary + public method signatures/JSDoc), + * harness events (mode + signature/JSDoc), and the inherited `ctx` surface. Produced by * the same AST walk as docs/cordis-catalog, so this data and the rendered * docs cannot diverge. * * @module @deepseek-ai/dsh-tool-cordis/api-catalog */ -/** One harness `ctx.` service: its one-line summary and public method signatures. */ +/** One public service method and its source-owned contract. */ +export interface ServiceApiMethod { + /** Public method signature with its body stripped. */ + signature: string + /** Original method JSDoc, with only container indentation removed. */ + jsDoc: string +} + +/** One harness `ctx.` service: its one-line summary and public methods. */ export interface ServiceApiEntry { /** The `ctx.` name, e.g. `tools`. */ key: string /** First sentence of the service class JSDoc. */ summary: string - /** Public method signatures, bodies stripped, in source order. */ - methods: readonly string[] + /** Public methods, bodies stripped, in source order. */ + methods: readonly ServiceApiMethod[] } /** One harness event: its dispatch mode, exact signature, and one-line summary. */ @@ -30,6 +38,8 @@ export interface EventApiEntry { mode: string /** The exact listener signature, whitespace-normalized. */ signature: string + /** Original event JSDoc, with only container indentation removed. */ + jsDoc: string /** First sentence of the event JSDoc. */ summary: string } @@ -54,476 +64,840 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentLoop', - summary: 'Concrete ReactLoopAgent factory and driver service.', + summary: 'Concrete agent factory and driver service.', methods: [ - 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', - 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', - 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', + { + signature: 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', + jsDoc: '/**\n * Create an agent and session under one caller-supplied identity, owned by\n * the accessing fiber. Constructor-driven config calls mint a fresh combined\n * id before entering this boundary.\n * @param id - shared agent/session identity.\n * @param options - concrete loop options.\n * @param meta - optional fresh-session workspace metadata.\n * @returns the published running agent.\n */', + }, + { + signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', + jsDoc: '/**\n * Create an owned agent on a caller-supplied session id.\n * @param ownerCtx - caller context that structurally owns the transaction.\n * @param options - identities, session seed/metadata, loop options, setup, and cancellation.\n * @returns the published handle.\n */', + }, + { + signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', + jsDoc: '/**\n * Resume an owned agent from the configured persistence service.\n * @param ownerCtx - caller context that owns load, setup, and the live lifecycle.\n * @param options - persisted identity, loop options, setup, and cancellation.\n * @returns the published handle.\n */', + }, ], }, { key: 'agents', - summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.', + summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', methods: [ - 'setFactory(factory: AgentFactory): () => void', - 'async create(options: CreateAgentOptions): Promise', - 'async resume(options: ResumeAgentOptions): Promise', - 'register(agent: Agent): () => void', - 'enter(agent: Agent): () => void', - 'announce(agent: Agent): void', - 'get(id: AgentId): Agent | undefined', - 'list(): Agent[]', + { + signature: 'currentInitiator(): Agent | undefined', + jsDoc: '/**\n * Read the Agent that initiated the inherited asynchronous driver chain.\n * Use this optional form for logging, tracing, metrics, or host attribution\n * that also supports agentless calls. When a parent creates a child, setup\n * reports the causal parent while `agentCtx.agent` identifies the child.\n * @returns the inherited Agent, or `undefined` outside an initiator boundary\n * and inside an explicit clearing boundary.\n * @throws when this service instance has been disposed.\n */', + }, + { + signature: 'requireInitiator(): Agent', + jsDoc: '/**\n * Read the initiating Agent and fail when no initiator boundary is active.\n * Use this for private helpers contractually below a driver, or for a\n * deployment-owned outbound request whose contract forbids agentless calls.\n * Generic or direct-call seams use optional lookup or explicit request fields.\n * @returns the inherited Agent.\n * @throws when no initiator is active or this service instance has been disposed.\n */', + }, + { + signature: 'withInitiator(agent: Agent, operation: () => T): T', + jsDoc: '/**\n * Run an operation with one exact Agent as its process-local initiator. The\n * exact synchronous value or Promise returned by the operation is preserved.\n * Custom drivers and test harnesses wrap their complete returned foreground\n * lifetime.\n * A queue or wire receiver may establish this boundary only after validating\n * explicit identity and resolving the exact live Agent; this method does neither.\n * Detached work remains owned by the subsystem that starts it.\n * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.\n * @param operation - synchronous or asynchronous operation to invoke.\n * @returns the exact value returned by `operation`.\n * @throws when the initiator scope is closing/disposed, or when `operation` throws.\n */', + }, + { + signature: 'withoutInitiator(operation: () => T): T', + jsDoc: '/**\n * Run an operation inside a boundary that hides any inherited initiating\n * Agent. The exact synchronous value or Promise is preserved.\n * Use this while creating lazy shared timers, queue pumps, pool maintenance,\n * watchers, or exporters so they do not inherit the first Agent that happens\n * to initialize them. It clears only initiator attribution, not explicit\n * fields, and does not own or drain detached resources.\n * @param operation - synchronous or asynchronous operation to invoke without an initiator.\n * @returns the exact value returned by `operation`.\n * @throws when the initiator scope is closing/disposed, or when `operation` throws.\n */', + }, + { + signature: 'setFactory(factory: AgentFactory): () => void', + jsDoc: '/**\n * Register the agent-creation factory (the loop calls this on construction,\n * effect-scoped). A traced Cordis service is canonicalized to its concrete\n * target; each create/resume call is then traced through that caller\'s\n * context so ownership follows the caller without stacking proxy layers.\n * Throws if a factory is already registered. Returns the disposer; on\n * dispose the factory slot is cleared.\n * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.\n * @returns the disposer that clears the factory slot. The exact\n * Cordis effect disposer (single-shot): composite (generator) effects may\n * yield it directly — exact identity nests the teardown in order.\n */', + }, + { + signature: 'async create(options: CreateAgentOptions): Promise', + jsDoc: '/**\n * Create and publish a new agent through the registered factory.\n * Distinct from {@link register} (which records an already-constructed\n * agent): this constructs the agent and its session. Rejects if no factory is\n * registered or creation/setup fails. The resolved {@link AgentHandle} lets\n * the owner tear down exactly this agent.\n * @param options - shared identity, session seed/metadata, and agent options.\n * @returns the handle after setup, rollback-covered publication, and loop start complete.\n */', + }, + { + signature: 'async resume(options: ResumeAgentOptions): Promise', + jsDoc: '/**\n * Load a persisted session and resume an agent on it through the registered\n * factory. Rejects if no factory is registered; the factory rejects if\n * session persistence is not configured or persistence/setup fails.\n * @param options - persisted identity, configuration, and optional setup.\n * @returns the handle after setup, rollback-covered publication, and loop start complete.\n */', + }, + { + signature: 'register(agent: Agent): () => void', + jsDoc: '/**\n * Register a live agent. Throws if an agent with the same id is already\n * registered. Emits `agent/created` on registration and `agent/disposed`\n * when the calling fiber is disposed — both with the agent\'s scope carrier\n * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the\n * emits are scope-filtered regardless of which context invoked `register`\n * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always\n * requires passing the carrier). Returns the disposer.\n * @param agent - the already-constructed agent to record in the store.\n * @returns the EXACT Cordis effect disposer (single-shot; a repeat call\n * returns undefined without awaiting an in-flight teardown). Exact\n * identity is load-bearing: a composite (generator) effect that owns a\n * teardown ORDER — the agent factory\'s lifecycle chain — must yield THIS\n * function so Cordis nests the unregistration at that yield position;\n * yielding a wrapper would leave it disposing as a concurrent sibling on\n * owner unload, unregistering the agent (and emitting `agent/disposed`)\n * while its final turn is still draining.\n */', + }, + { + signature: 'enter(agent: Agent, owner: Agent | undefined): () => void', + jsDoc: '/**\n * Insert an already-constructed agent without announcing it. This is the\n * advanced ordered-lifecycle primitive used by the async agent factory: it\n * first completes setup while the agent is unpublished, then assigns the\n * returned detach closure into its pre-installed composite teardown before\n * calling {@link announce}. Ordinary callers use {@link register}.\n * @param agent - the prepared, unpublished agent.\n * @param owner - live agent whose scoped context created this agent, or\n * undefined for a top-level runtime root. This is runtime ownership, not\n * the resumed session\'s durable parent lineage.\n * @returns an idempotent closure that removes this exact entry and emits\n * `agent/disposed` with listener failures contained. When called from a\n * synchronous `agent/created` listener, removal and disposal wait until\n * that creation dispatch unwinds.\n */', + }, + { + signature: 'announce(agent: Agent): void', + jsDoc: '/**\n * Announce an agent previously inserted with {@link enter}.\n * @param agent - the live inserted agent to announce.\n * @throws if `agent` is not the exact live registry entry for its id, or its\n * creation announcement already began (including a reentrant call from a\n * creation listener).\n */', + }, + { + signature: 'get(id: SessionId): Agent | undefined', + jsDoc: '/**\n * Look up a live agent.\n * @param id - the shared agent/session id to look up.\n * @returns the agent, or undefined when no live agent has that id.\n */', + }, + { + signature: 'isOwnedBy(id: SessionId, owner: Agent): boolean', + jsDoc: '/**\n * Test whether a live agent was created through one exact parent agent\'s\n * scoped context. Runtime ownership is independent of durable session\n * lineage and remains unambiguous when unrelated providers reuse an id.\n * @param id - the candidate child agent\'s shared agent/session id.\n * @param owner - the expected runtime creator agent.\n * @returns true only while the exact child entry is live under that owner.\n */', + }, + { + signature: 'list(): Agent[]', + jsDoc: '/**\n * All live agents, in registration order.\n * @returns a fresh array; mutating it does not affect the registry.\n */', + }, + { + signature: 'roots(): Agent[]', + jsDoc: '/**\n * All live top-level agents in registration order. A top-level agent was\n * created without an owning agent context; durable session lineage does not\n * affect this runtime relation, so a resumed fork may still be a root.\n * @returns a fresh array; mutating it does not affect the registry.\n */', + }, ], }, { key: 'approval', summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', methods: [ - 'async request(req: ApprovalRequest): Promise', + { + signature: 'async request(req: ApprovalRequest): Promise', + jsDoc: '/**\n * Ask the composed answerers to decide one readonly same-process request.\n * The service borrows the request, agent, session, and live signal directly.\n * The request requires an open turn because the audit pair must be enclosed\n * by the durable log\'s commit/replay boundary; an idle ask rejects before\n * appending anything. The answerer phase always produces an outcome: an\n * aborted signal yields `\'cancelled\'`, a missing or throwing answerer yields\n * `\'unavailable\'` (fail closed), and a rogue non-vocabulary return value is\n * normalized to `\'unavailable\'`. A failure that prevents either audit append\n * from committing still rejects because returning an unlogged decision would\n * violate the pair. Session contains post-commit observer failures, so an\n * authoritative append cannot reject the request or suppress its matching\n * audit event.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @returns the closed outcome; `\'allowed-once\'` is the only grant.\n * @throws when no turn is open or either audit event fails before the session\n * append commit point.\n */', + }, ], }, { key: 'bash', summary: 'Abstract bash execution service.', methods: [ - 'abstract resolve(request: BashExecRequest): BashExecSpec', - 'abstract run(spec: BashExecSpec): Promise', - 'abstract start(spec: BashExecSpec): BashProcess', + { + signature: 'abstract resolve(request: BashExecRequest): BashExecSpec', + jsDoc: '/**\n * Apply implementation-owned defaults and caps to a request before execution.\n * @param request - the caller\'s request; omitted fields get this\n * implementation\'s defaults, capped fields are clamped.\n * @returns the fully-specified spec to hand to {@link run}/{@link start}.\n */', + }, + { + signature: 'abstract run(spec: BashExecSpec): Promise', + jsDoc: '/**\n * Run a command in the foreground; resolves when it finishes.\n * @param spec - a resolved spec from {@link resolve}, never a raw request.\n * @returns the outcome; nonzero exits, timeout kills, and abort kills\n * resolve with a descriptive result rather than reject.\n */', + }, + { + signature: 'abstract start(spec: BashExecSpec): BashProcess', + jsDoc: '/**\n * Start a background process and return its handle immediately.\n * @param spec - a resolved spec from {@link resolve}, never a raw request.\n * @returns the live process handle (reads, kill, quiescence promise).\n */', + }, ], }, { key: 'bashEnv', summary: 'Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables.', methods: [ - 'register(contributor: BashEnvContributor): () => void', - 'collect(execution: ToolExecution): DshEnvironment', - 'list(): BashEnvVariableInfo[]', + { + signature: 'register(contributor: BashEnvContributor): () => void', + jsDoc: '/**\n * Register one environment contributor. Names and keys are unique; built-in\n * keys are reserved. Registration is disposed with the calling plugin fiber.\n * @param contributor - declared key ownership and per-execution resolver.\n * @returns the disposer that unregisters the contribution.\n */', + }, + { + signature: 'collect(execution: ToolExecution): DshEnvironment', + jsDoc: '/**\n * Build the trusted `DSH_*` snapshot for one bash tool execution.\n * @param execution - the current tool execution.\n * @returns an immutable environment overlay containing built-ins and current contributions.\n */', + }, + { + signature: 'list(): BashEnvVariableInfo[]', + jsDoc: '/**\n * Enumerate plugin-contributed variables without executing their resolvers.\n * @returns declarations sorted by environment variable name.\n */', + }, ], }, { key: 'codeRuntime', summary: 'Registers one `ctx.codeRuntime` implementation.', methods: [ - 'abstract run(request: CodeRunRequest): Promise', + { + signature: 'abstract run(request: CodeRunRequest): Promise', + jsDoc: '/**\n * Execute one program against the request\'s bindings and capture what it\n * emitted. See the class doc for the resolution contract (error is a result\n * field; rejection means seam misuse only).\n * @param request - the program, its bindings, and the abort signal; the\n * request carries everything the runtime acts on, with no hidden defaults.\n * @returns the run\'s outcome: completion value (when transferable), the\n * ordered log capture, and the failure (if any).\n */', + }, ], }, { key: 'compact', summary: 'Abstract compaction service.', methods: [ - 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise', - 'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', + { + signature: 'abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * Consider automatic compaction for one explicit trigger. Pressure policy\n * uses the latest durable routed request, while context-overflow policy may\n * force a useful balanced reduction even below the normal threshold. Return\n * `null` when no safe range can be compacted. A single oversized retained\n * unit or request envelope cannot be repaired through surface compaction.\n *\n * @param agent - agent context owning the session surface and routing options.\n * @param trigger - normal pressure or provider-confirmed context overflow.\n * @param signal - cancellation signal; model-backed implementations must forward it.\n * @returns the compaction result, or `null` if no compaction was needed.\n */', + }, + { + signature: 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Forcibly compact a range of surface nodes into a single summary node.\n * `start` and `end` name an inclusive span by surface position, not numeric seq\n * order; replacements can make visible seqs non-monotonic. Both edges must be\n * balanced so assistant tool calls remain paired with their results. A model-\n * backed implementation forwards cancellation and rejects active, missing,\n * reversed, or unbalanced ranges. The target session is `agent.session`.\n * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}\n * for the edge checks.\n *\n * @param start - first surface seq, inclusive.\n * @param end - last surface seq, inclusive.\n * @param agent - context whose session is mutated and whose routing options guide summarization.\n * @param signal - optional cancellation; model-backed implementations must forward it.\n * @throws when compaction is active or the range is missing, reversed, or unbalanced.\n * @returns the appended event seqs, summary, replaced range, and token accounting.\n */', + }, ], }, { key: 'fs', summary: 'Abstract filesystem provider.', methods: [ - 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise', - 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise', - 'abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise', - 'abstract readText(target: FsTarget, signal?: AbortSignal): Promise', - 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', - 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', - 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise', - 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise', + { + signature: 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise', + jsDoc: '/**\n * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a\n * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence\n * async even though the local backend only normalizes + realpaths.\n *\n * @param path - the path to resolve; relative paths resolve against `opts.cwd`.\n * @param opts - optional cwd override and cancellation signal.\n * @returns the stable target; the same file yields the same `targetKey`.\n */', + }, + { + signature: 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Return target metadata, or `undefined` when the target does not exist.\n * @param target - the resolved target to stat.\n * @param signal - aborts the metadata round-trip.\n * @returns metadata only, never content; undefined for an absent target.\n */', + }, + { + signature: 'abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Return path metadata without following the final path component when it is a\n * symbolic link. This is intentionally path-shaped, not target-shaped:\n * {@link resolve} follows symlinks to produce the stable identity used by\n * normal reads/writes, while `lstat` lets a consumer reject the path itself\n * before that follow happens.\n *\n * `opts.cwd` follows {@link resolve}\'s cwd rules. `undefined` means the path is\n * absent.\n * @param path - the path to inspect; relative paths resolve against `opts.cwd`.\n * @param opts - `cwd` overrides the backend\'s default base for relative paths.\n * @param signal - aborts the metadata round-trip.\n * @returns metadata only, never content; undefined for an absent path.\n */', + }, + { + signature: 'abstract readText(target: FsTarget, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read the whole regular text file as a single decoded string.\n * @param target - the resolved target to read.\n * @param signal - aborts the read.\n * @returns the full decoded UTF-8 content.\n */', + }, + { + signature: 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', + jsDoc: '/**\n * Stream the whole regular text file as decoded text chunks (same text\n * semantics as {@link readText}, for large files). The backend owns\n * cross-chunk UTF-8 decoding and binary rejection so the policy layer never\n * touches raw bytes.\n * @param target - the resolved target to read.\n * @param signal - aborts the stream, including between chunks.\n * @returns the chunk iterable, decoded and validated like {@link readText}.\n */', + }, + { + signature: 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', + jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */', + }, + { + signature: 'abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Atomically create or replace UTF-8 text. `expected` guards intent and\n * staleness; omission allows unconditional overwrite.\n * @param target - the resolved target to write.\n * @param content - the full new file content.\n * @param expected - the write intent guarding the write; omit for unconditional.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the write produced.\n */', + }, + { + signature: 'abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Atomically edit literal text. When supplied, the version guard is checked\n * before matching so stale content reports `FS_STALE_VERSION`; omission edits\n * the current content without a freshness precondition.\n * @param target - the resolved target to edit.\n * @param edit - the literal search/replace request.\n * @param expected - the version guard; omit for an unconditional edit.\n * @param signal - aborts before the atomic rename takes effect.\n * @returns the outcome, including the version the edit produced.\n */', + }, ], }, { key: 'llm', summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.', methods: [ - 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', - 'listProviders(): LlmProviderInfo[]', - 'async listModels(provider: string): Promise', - 'stream(options: GenerateOptions): AsyncIterable', + { + signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void', + jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all of them.\n */', + }, + { + signature: 'listProviders(): LlmProviderInfo[]', + jsDoc: '/**\n * Describe provider routes with a registered adapter.\n * @returns detached provider metadata in registration order.\n */', + }, + { + signature: 'async listModels(provider: string): Promise', + jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', + }, + { + signature: 'stream(options: GenerateOptions): AsyncIterable', + jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', + }, ], }, { key: 'permission', summary: 'Owns the deployment\'s permission presets and their write path.', methods: [ - 'current(events: readonly SessionEvent[]): string', - 'resolve(name: string): PresetSpec', - 'optionOf(name: string): PresetOption', - 'set(session: Session, name: string): void', + { + signature: 'current(events: readonly SessionEvent[]): string', + jsDoc: '/**\n * Resolve the preset matching the effective knob values. A still-matching\n * last selection wins shared-bundle ties; otherwise the first table match\n * wins, or {@link CUSTOM_PRESET} when no entry matches.\n * @param events - the session\'s events in log order.\n * @returns the effective preset name, or `custom` when nothing matches.\n */', + }, + { + signature: 'resolve(name: string): PresetSpec', + jsDoc: '/**\n * Resolve a preset\'s knob bundle.\n * @param name - the preset name to resolve.\n * @returns the configured bundle.\n * @throws when `name` is not in the table.\n */', + }, + { + signature: 'optionOf(name: string): PresetOption', + jsDoc: '/**\n * Build the client option for a table entry or {@link CUSTOM_PRESET}. A\n * missing label falls back to the table key.\n * @param name - a table key, or `custom`.\n * @returns the option a client renders.\n * @throws when `name` is neither a table key nor `custom`.\n */', + }, + { + signature: 'set(session: Session, name: string): void', + jsDoc: '/**\n * Record a changed preset, then update each changed knob through its own\n * setter. Selecting the effective preset again appends nothing.\n * @param session - the session the switch belongs to.\n * @param name - the preset to switch to; unknown names throw.\n */', + }, ], }, { key: 'sandbox', summary: 'Abstract process-sandbox service.', methods: [ - 'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv', + { + signature: 'abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv', + jsDoc: '/**\n * Wrap `argv` so it executes confined under `policy` on this host; the\n * caller spawns the returned argv in place of its own.\n * @param argv - the exact argv the caller is about to spawn (program plus\n * arguments), NOT a shell string — a shell-shaped consumer passes\n * `[\'bash\', \'-c\', command]`.\n * @param policy - the file-effect policy this execution runs under,\n * carried per call (see {@link SandboxPolicy}).\n * @returns the argv to spawn instead, plus the enforcement completeness\n * the selected backend achieves for it.\n */', + }, ], }, { key: 'sessionPersistence', summary: 'Durable append-only session storage.', methods: [ - 'abstract locate(meta: SessionHeader): SessionLocation | undefined', - 'abstract create(meta: SessionHeader): Promise', - 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', - 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - 'abstract list(): Promise', + { + signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', + jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', + }, + { + signature: 'abstract create(meta: SessionHeader): Promise', + jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', + }, + { + signature: 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', + jsDoc: '/**\n * Durably persist a batch of events (called from the write-behind drain at\n * the `session/flush` checkpoint). Honors the append-only and contiguous-seq\n * contracts: the first event\'s `seq` MUST equal the stored next-seq (after\n * `load` has durably closed any interrupted turn). Rejects non-JSON-\n * serializable `event.data` with an error naming the offending event type.\n * @param id - the session the batch belongs to.\n * @param events - the contiguous batch to persist, in seq order.\n */', + }, + { + signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + }, + { + signature: 'abstract list(): Promise', + jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @returns one header per materialized session.\n */', + }, ], }, { key: 'sessionQuery', summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.', methods: [ - 'listSessions(): Promise', - 'async listEvents(sessionId: SessionId): Promise', - 'async traceSession(sessionId: SessionId): Promise', - 'async traceEvent(request: SessionEventTraceRequest): Promise', - 'async readEvent(request: SessionEventReadRequest): Promise', + { + signature: 'listSessions(): Promise', + jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', + }, + { + signature: 'async listEvents(sessionId: SessionId): Promise', + jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', + }, + { + signature: 'async traceSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + }, + { + signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', + jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns direct links plus the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', + }, + { + signature: 'async readEvent(request: SessionEventReadRequest): Promise', + jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @returns cloned target and neighboring events.\n */', + }, ], }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', methods: [ - 'create(id?: SessionId, options?: CreateSessionOptions): Session', - 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', - 'enter(session: Session): () => void', - 'announce(session: Session): void', - 'async flush(session: Session): Promise', - 'get(id: SessionId): Session | undefined', - 'list(): Session[]', - 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', + { + signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session', + jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`,\n * `parentSession` lineage) as the immutable {@link SessionHeader} (the store\n * fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final flush is captured before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */', + }, + { + signature: 'prepare(id?: SessionId, options?: CreateSessionOptions): Session', + jsDoc: '/**\n * Build a session WITHOUT entering it into the store — validate the id/cwd and\n * construct the {@link Session} (with its immutable {@link SessionHeader}).\n * Pairs with {@link enter} + {@link announce}: a caller that owns a composite\n * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE\n * effect so a fiber unload tears the session + agent down as a single ORDERED\n * chain rather than as racing sibling effects — which would remove the publication hooks\n * before the loop\'s closing `session/flush`, dropping the closing events.\n *\n * @param id - the session id; omitted, the store mints `session-`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the constructed session, NOT yet in the store.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path.\n */', + }, + { + signature: 'enter(session: Session): () => void', + jsDoc: '/**\n * Enter a {@link prepare}d session into the store: install the module-private\n * append publication hooks and add it to the store. Returns the DETACH\n * disposer (hooks + store removal). Does NOT emit `session/created` —\n * the caller yields this disposer inside its effect and THEN calls\n * {@link announce}, so a throwing `session/created` listener rolls the attach\n * back instead of leaking it.\n *\n * Re-checks the id for a duplicate: `prepare` and `enter` are public\n * cross-package primitives and a caller may interleave arbitrary work (or\n * another create) between them, so a stale prepared session must NOT overwrite\n * a live store entry of the same id — its detach disposer would later delete\n * the REAL session. The {@link create} convenience and the agent factory call\n * the two back-to-back so they never trip this, but the public seam cannot\n * assume that.\n *\n * @param session - a {@link prepare}d session not yet in the store.\n * @returns the detach disposer (publication hooks + store removal). When called from\n * a synchronous `session/created` listener, removal and disposal wait until\n * that creation dispatch unwinds.\n * @throws if a session with this id is already in the store.\n */', + }, + { + signature: 'announce(session: Session): void', + jsDoc: '/** Emit `session/created` exactly once for an {@link enter}ed session (with\n * the carrier {@link enter} captured). Separate from {@link enter} so the\n * caller can yield the detach disposer first (rollback safety — see\n * {@link enter}).\n * @param session - the entered session to announce to listeners.\n * @throws if the session is not live or its announcement already began,\n * including a reentrant call from a creation listener. */', + }, + { + signature: 'async flush(session: Session): Promise', + jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */', + }, + { + signature: 'get(id: SessionId): Session | undefined', + jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */', + }, + { + signature: 'list(): Session[]', + jsDoc: '/**\n * All live sessions, in creation order.\n * @returns a fresh array; mutating it does not affect the store.\n */', + }, + { + signature: 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', + jsDoc: '/**\n * Create a live child session from a turn-enclosed prefix of a live source.\n * `boundary` is an inclusive source event seq; omitted means the source\'s\n * current last event. A non-empty selected slice must end at `turn/end`.\n *\n * @param source - Live source session object or id.\n * @param boundary - Inclusive source event seq to fork through; omitted means\n * the source\'s current last event, and omitted on an empty source forks an\n * empty child.\n * @param childSessionId - Optional child session id; omitted delegates to\n * `SessionStore`\'s id policy.\n * @returns The created live child session.\n */', + }, ], }, { key: 'skills', summary: 'Registry of skill providers.', methods: [ - 'registerProvider(provider: SkillProvider): () => void', - 'register(skill: SkillRegistration): () => void', - 'async list(options: SkillLookupOptions = {}): Promise', - 'async get(name: string, options: SkillLookupOptions = {}): Promise', + { + signature: 'registerProvider(provider: SkillProvider): () => void', + jsDoc: '/**\n * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and\n * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters\n * the provider and invalidates catalog caches.\n * @param provider - the provider to register by `provider.name`.\n * @returns the exact Cordis effect disposer that unregisters this provider;\n * composite effects may yield it directly to preserve teardown ordering.\n */', + }, + { + signature: 'register(skill: SkillRegistration): () => void', + jsDoc: '/**\n * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which\n * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and\n * receives a no-op disposer so it cannot remove the winner.\n * @param skill - the complete skill definition to expose for discovery.\n * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.\n */', + }, + { + signature: 'async list(options: SkillLookupOptions = {}): Promise', + jsDoc: '/**\n * List model-invocable skill summaries for a workspace. Lookup options and\n * provider candidates are readonly same-process values borrowed throughout\n * discovery.\n * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.\n * @returns sorted summaries, excluding skills disabled for model invocation.\n */', + }, + { + signature: 'async get(name: string, options: SkillLookupOptions = {}): Promise', + jsDoc: '/**\n * Load and validate the winning candidate, passing its opaque discovery locator back to the\n * provider. Cancellation is rechecked after selection, including cache hits, and raced against\n * loading so an uncooperative provider cannot hang the caller.\n * @param name - kebab-case skill name.\n * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.\n * @returns the full skill, including body content, or `undefined`.\n */', + }, ], }, { key: 'spillStore', summary: 'Abstract spill storage service.', methods: [ - 'abstract saveText(input: SaveTextSpill): Promise', + { + signature: 'abstract saveText(input: SaveTextSpill): Promise', + jsDoc: '/**\n * Persist `input.content` to a session-scoped spill artifact.\n * @param input - the owner, provenance, suggested name, and full text to save.\n * @returns the saved artifact\'s {@link SpillRef}; rejects on a storage failure.\n */', + }, ], }, { key: 'subagents', summary: 'Named provider registry and capability-checked start surface.', methods: [ - 'registerProvider(provider: SubagentProvider): () => void', - 'getProvider(name: string): SubagentProvider | undefined', - 'list(): string[]', - 'async start(name: string, request: SubagentStartRequest): Promise', + { + signature: 'registerProvider(provider: SubagentProvider): () => void', + jsDoc: '/**\n * Register a provider under its name. Registration is effect-scoped and HMR\n * safe; removing a provider blocks new starts but does not revoke runs that\n * were already returned to their holders.\n * @param provider - the trusted provider implementation.\n * @returns the exact Cordis effect disposer.\n */', + }, + { + signature: 'getProvider(name: string): SubagentProvider | undefined', + jsDoc: '/**\n * Look up a provider by name.\n * @param name - the provider name.\n * @returns the provider, or undefined when absent.\n */', + }, + { + signature: 'list(): string[]', + jsDoc: '/**\n * List registered provider names in insertion order.\n * @returns the registered names.\n */', + }, + { + signature: 'async start(name: string, request: SubagentStartRequest): Promise', + jsDoc: '/**\n * Establish a ready child on the named provider. Capability and semantic\n * checks run before delegation. Provider ownership lasts until its promise\n * fulfills; a rejection therefore has no run for the caller to dispose and\n * emits no run lifecycle events.\n * @param name - the provider to use.\n * @param request - child prompt, parent, signal, and optional capabilities.\n * @returns the ready holder-owned run.\n */', + }, ], }, { key: 'systemPrompt', summary: 'Registry service for the prompt inputs assembled before each model step.', methods: [ - 'section(section: PromptSection): () => void', - 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', - 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void', - 'async assemble(context: AssembleContext = {}): Promise', + { + signature: 'section(section: PromptSection): () => void', + jsDoc: '/**\n * Register an ordered prompt section in the calling context\'s scope. A scoped\n * section shadows a global section with the same name; duplicates within one\n * layer and non-finite orders throw. Registration and disposal emit\n * `system-prompt/change`.\n * @param section - the section to register.\n * @returns the exact Cordis effect disposer.\n */', + }, + { + signature: 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', + jsDoc: '/**\n * Register a tool-schema provider in the calling context\'s scope. Global and\n * matching scoped providers both contribute; returning the reserved\n * {@link TOOL_ORDER_REST} name makes assembly fail.\n * @param provider - evaluated for each assembly with its context.\n * @returns the exact Cordis effect disposer.\n */', + }, + { + signature: 'variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void', + jsDoc: '/**\n * Register a prompt variable in the calling context\'s scope. Scoped values\n * shadow globals; invalid or duplicate names throw. A provider may return\n * `undefined`, but rendering a section that references that value then fails.\n * @param name - the `[a-z][a-z0-9_]*` reference name.\n * @param provider - evaluated for each assembly.\n * @returns the exact Cordis effect disposer.\n */', + }, + { + signature: 'async assemble(context: AssembleContext = {}): Promise', + jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */', + }, ], }, { key: 'tasks', summary: 'The `tasks` service: the runtime-global background task registry.', methods: [ - 'start(spec: TaskStart): TaskId', - 'list(caller?: Agent): TaskSnapshot[]', - 'get(id: TaskId, caller?: Agent): TaskSnapshot', - 'read(id: TaskId, caller?: Agent): TaskRead', - 'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', - 'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', - 'onTaskDone(listener: TaskDoneListener): () => void', - 'attachSurface(name: string): () => void', + { + signature: 'start(spec: TaskStart): TaskId', + jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', + }, + { + signature: 'list(caller?: Agent): TaskSnapshot[]', + jsDoc: '/**\n * List caller-owned and unowned tasks in registration order without exposing\n * another session\'s labels.\n * @param caller - reading agent; a non-agent caller sees only unowned tasks.\n * @returns fresh snapshots.\n */', + }, + { + signature: 'get(id: TaskId, caller?: Agent): TaskSnapshot', + jsDoc: '/**\n * Return a non-consuming snapshot without changing its read cursor or notice\n * state. Throws for an unknown or foreign task.\n * @param id - task to look up.\n * @param caller - reading agent checked against the owner.\n * @returns a fresh snapshot.\n */', + }, + { + signature: 'read(id: TaskId, caller?: Agent): TaskRead', + jsDoc: '/**\n * Read the next stream delta, or the idempotent final output after settlement.\n * A terminal read marks the task reported. Throws for an unknown or foreign\n * task.\n * @param id - task to read.\n * @param caller - reading agent checked against the owner.\n * @returns output text and the post-read snapshot.\n */', + }, + { + signature: 'kill(id: TaskId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'', + jsDoc: '/**\n * Request cancellation, then mark the task stopping and reported. A producer\n * throw propagates without changing task state. Throws for an unknown or\n * foreign task.\n * @param id - task to cancel.\n * @param caller - killing agent checked against the owner.\n * @param reason - logged reason forwarded to the producer.\n * @returns `requested` for live work, otherwise `already-finished`.\n */', + }, + { + signature: 'async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Wait for settlement or timeout without cancelling the task. Caller abort\n * rejects only while the task is live; after settlement it returns the\n * terminal snapshot so a notice suppressed for this waiter is still delivered.\n * Timed-out and aborted waits detach their resolvers. Throws for invalid,\n * unknown, or foreign input.\n * @param id - task to wait for.\n * @param timeoutMs - positive finite wait bound in milliseconds.\n * @param caller - waiting agent checked against the owner.\n * @param signal - optional cancellation of the wait itself.\n * @returns snapshot at settlement or timeout.\n */', + }, + { + signature: 'onTaskDone(listener: TaskDoneListener): () => void', + jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', + }, + { + signature: 'attachSurface(name: string): () => void', + jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', + }, ], }, { key: 'tokenMeter', summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', methods: [ - 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', - 'estimateMessage(message: Message): number', + { + signature: 'measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement', + jsDoc: '/**\n * Measure current request pressure and surface through the durable tail.\n *\n * Provider usage is reused only when the latest successful call\'s canonical\n * request envelope matches `requestHeader` and its total is no lower than\n * that call\'s full heuristic anchor; otherwise the complete envelope and\n * surface are heuristically repriced.\n *\n * `requestHeader` affects request pressure only; surface fields always\n * describe the current session surface. Every call clones those positional\n * nodes, so measurement is O(surface).\n *\n * @param session - session to replay through its current durable tail.\n * @param requestHeader - optional effective request envelope replacing the latest logged header.\n * @returns a detached deeply immutable pressure and surface measurement.\n */', + }, + { + signature: 'estimateMessage(message: Message): number', + jsDoc: '/**\n * Heuristically price one model-visible message.\n * @param message - message to price without mutation.\n * @returns content and role-framing tokens under the fixed service heuristic.\n */', + }, ], }, { key: 'tools', summary: 'Tool registry and execution pipeline.', methods: [ - 'register(definition: ToolDefinition): () => void', - 'restrict(filter: ToolRestriction): () => void', - 'guard(guard: ToolGuard): () => void', - 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', - 'schemas(scope?: ScopeKey): ToolSchema[]', - 'async execute(exec: ToolExecutionInput): Promise', + { + signature: 'register(definition: ToolDefinition): () => void', + jsDoc: '/**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - the tool schema, execution, and optional presentation functions.\n * @returns the exact disposer that unregisters the tool.\n */', + }, + { + signature: 'restrict(filter: ToolRestriction): () => void', + jsDoc: '/**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */', + }, + { + signature: 'guard(guard: ToolGuard): () => void', + jsDoc: '/**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */', + }, + { + signature: 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', + jsDoc: '/**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */', + }, + { + signature: 'schemas(scope?: ScopeKey): ToolSchema[]', + jsDoc: '/**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */', + }, + { + signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', + jsDoc: '/**\n * Classify a pending call through the caller\'s visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */', + }, + { + signature: 'async execute(exec: ToolExecutionInput): Promise', + jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy, and final\n * notification. Tool and listener failures resolve as materialized error\n * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is\n * the same lossless, frozen snapshot final observers receive.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', + }, ], }, { key: 'userInteraction', summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.', methods: [ - 'registerProvider(provider: UserInteractionProvider): () => void', - 'async ask(request: AskUserQuestionRequest): Promise', + { + signature: 'registerProvider(provider: UserInteractionProvider): () => void', + jsDoc: '/**\n * Register the UI provider. Only one provider may be active in a context.\n *\n * @param provider UI-side implementation that collects answers.\n * @returns Disposer that unregisters this provider.\n */', + }, + { + signature: 'async ask(request: AskUserQuestionRequest): Promise', + jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n */', + }, ], }, { key: 'web', summary: 'The web access service.', methods: [ - 'registerSearchProvider(provider: WebSearchProvider): () => void', - 'registerFetchProvider(provider: WebFetchProvider): () => void', - 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', - 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise', + { + signature: 'registerSearchProvider(provider: WebSearchProvider): () => void', + jsDoc: '/**\n * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`\n * if its id is already registered for search. Returns a disposer; disposed\n * with the calling fiber.\n * @param provider - the provider; its `id` is the registry key.\n * @returns the disposer that unregisters the provider.\n */', + }, + { + signature: 'registerFetchProvider(provider: WebFetchProvider): () => void', + jsDoc: '/**\n * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`\n * if its id is already registered for fetch. Returns a disposer; disposed\n * with the calling fiber.\n * @param provider - the provider; its `id` is the registry key.\n * @returns the disposer that unregisters the provider.\n */', + }, + { + signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query plus result-shaping options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */', + }, + { + signature: 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Retrieve one URL through the selected provider. Resolves the provider at\n * call time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. A non-2xx response is a result, not a throw.\n * @param request - the URL plus retrieval options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the retrieval outcome; non-2xx responses resolve descriptively.\n */', + }, ], }, { key: 'workflows', summary: 'Workflow execution seam.', methods: [ - 'abstract start(request: WorkflowStartRequest): WorkflowRun', + { + signature: 'abstract start(request: WorkflowStartRequest): WorkflowRun', + jsDoc: '/**\n * Parse and execute a workflow script.\n * @param request - the script, its `args`, the parent agent, and an\n * optional cancel signal.\n * @returns the live run; its `result` resolves when the script settles.\n */', + }, ], }, ] /** Every harness event, sorted by name. */ export const EVENT_API: readonly EventApiEntry[] = [ + { + name: 'agent-loop/config-start-failed', + mode: 'emit', + signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', + jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', + summary: 'A declarative agent entry failed before it could publish a live agent.', + }, { name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', + jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', + jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * but before session detachment and scoped-registration unwind. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind.', }, { name: 'agent/error', mode: 'emit', signature: '\'agent/error\'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void', + jsDoc: '/**\n * A step or turn errored. The loop reports a failure here (plus the logger)\n * even when the error has no in-turn position for a session `error` event.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'A step or turn errored.', }, + { + name: 'agent/post-step', + mode: 'serial', + signature: '\'agent/post-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * Awaited serial checkpoint after the response, real or synthetic tool\n * results, injected context, and steering are durable but before `step/end`.\n * A cancelled tool batch reaches this checkpoint with an aborted signal.\n * @param agent - the agent whose step is settling.\n * @param turn - the open turn number.\n * @param step - the open step number.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', + summary: 'Awaited serial checkpoint after the response, real or synthetic tool results, injected context, and steering are durable but before `step/end`.', + }, { name: 'agent/pre-step', mode: 'serial', - signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void', - summary: 'Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step.', + signature: '\'agent/pre-step\'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void', + jsDoc: '/**\n * Awaited serial checkpoint before `step/start`; appends land outside the\n * pending step and are included when the loop derives request history.\n * `signal` cancels listener work.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent opening the step.\n * @param turn - the open turn number.\n * @param step - the pending step number.\n * @param signal - the turn abort signal.\n * @mode serial\n */', + summary: 'Awaited serial checkpoint before `step/start`; appends land outside the pending step and are included when the loop derives request history.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', + jsDoc: '/**\n * Allow, rewrite, or block one drained prompt before it becomes a user\n * message. Call `next()` for the unchanged default.\n * @param agent - the agent draining its inbox.\n * @param content - the drained message\'s blocks, as queued.\n * @param source - the message\'s resolved source.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Allow, rewrite, or block one drained prompt before it becomes a user message.', }, { name: 'agent/queued', mode: 'emit', signature: '\'agent/queued\'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void', + jsDoc: '/**\n * Detached, frozen content entered the agent\'s inbox. Source defaults have\n * already been applied, so these are the exact values retained for the log.\n * @param agent - the agent whose inbox received the message.\n * @param content - the accepted content blocks retained by the inbox.\n * @param info - the accepted source plus whether it entered as steering.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Detached, frozen content entered the agent\'s inbox.', }, { name: 'agent/request', mode: 'waterfall', signature: '\'agent/request\'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise', + jsDoc: '/**\n * Replace the frozen call configuration. Model-visible content must use\n * logged channels; this seam cannot mutate messages. Injection here joins\n * the next request because the current step boundary is already fixed.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param config - the config the loop would use (frozen); return a replacement to switch.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Replace the frozen call configuration.', }, + { + name: 'agent/request-error', + mode: 'waterfall', + signature: '\'agent/request-error\'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param retryAttempt - zero-based number of prior recovery retries.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', + summary: 'Recover a model-request failure after its failed step has closed.', + }, { name: 'agent/session-prefix', mode: 'waterfall', signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', + jsDoc: '/**\n * Compose request-only messages placed before derived history. The frozen\n * result is computed once per loop instance, logged on its anchoring request\n * header, and reused so the provider prefix remains stable. Interrupted\n * composition is discarded. Composition precedes the first `agent/pre-step`\n * and request boundary, so listener appends join the current request.\n * Changing context belongs in history; contributors should prepend to\n * `await next()` to preserve registration order.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - the agent whose session prefix is being composed.\n * @param prefix - the frozen seed; return an extended replacement.\n * @param signal - aborts composition when the step is torn down.\n * @mode waterfall\n */', summary: 'Compose request-only messages placed before derived history.', }, { name: 'agent/session-start', mode: 'emit', signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', + jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', mode: 'emit', signature: '\'agent/status\'(this: Scoped, agent: Agent, status: AgentStatus): void', + jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does\n * not enter `running` synchronously; drive lifecycle from this event.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Agent status changed (`idle` ⇄ `running`, or → `disposed`).', }, { name: 'agent/step-result', mode: 'waterfall', signature: '\'agent/step-result\'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise', + jsDoc: '/**\n * Waterfall: post-process the assembled assistant {@link Message} before\n * tool dispatch (validation, content rewriting, …).\n * @param agent - the agent that received the step\'s response.\n * @param turn - the open turn number.\n * @param step - the step that produced the message.\n * @param message - the assistant message as assembled from the stream.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).', }, { name: 'agent/turn-continuation', mode: 'waterfall', signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', + jsDoc: '/**\n * Override whether the turn continues. The default continues after tool\n * calls or steering and stops otherwise; a continue reason becomes steering.\n * @param agent - the agent deciding whether to run another step.\n * @param turn - the turn being continued or stopped.\n * @param defaultDecision - what the loop would do absent an override.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', + jsDoc: '/**\n * Monotonic terminal-stop checkpoint after continuation and steering are\n * folded; a stop remains authoritative through turn close and flush:\n * steering queued in that window is discarded, while ordinary sends survive.\n * @param agent - the agent whose composed continuation outcome may be stopped.\n * @param turn - the turn at its terminal-stop checkpoint.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.', }, { name: 'approval/request', mode: 'waterfall', signature: '\'approval/request\'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise', + jsDoc: '/**\n * Ask composed answerers for one decision. Return an outcome to claim the\n * request or call `next()`; failure yields the fail-closed default.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param req - the pending decision (agent, tool identity, reason, signal).\n * @mode waterfall\n */', summary: 'Ask composed answerers for one decision.', }, { name: 'fs/edit-intent', mode: 'waterfall', signature: '\'fs/edit-intent\'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>', + jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.editText}. Calling\n * `next()` yields an unconditional edit; the first returned guard wins.\n * @param target - the resolved target about to be edited.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */', summary: 'Single-slot decision for the next FileSystem.editText.', }, { name: 'fs/observed', mode: 'emit', signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void', + jsDoc: '/**\n * Record a successful observation. Listeners must be synchronous recorders:\n * throws fail the tool call and returned promises are not awaited.\n * @param target - the target that was read/written/edited.\n * @param version - the version the actor now holds as its observation.\n * @param actor - the observing tool-execution context; undefined records nothing useful.\n * @mode emit\n */', summary: 'Record a successful observation.', }, { name: 'fs/write-intent', mode: 'waterfall', signature: '\'fs/write-intent\'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise', + jsDoc: '/**\n * Single-slot decision for the next {@link FileSystem.writeText}. Calling\n * `next()` yields the bare provider\'s unconditional write; the first listener\n * that returns an intent owns the decision rather than composing with peers.\n * @param target - the resolved target about to be written.\n * @param actor - the opaque tool-execution context the decider keys off.\n * @mode waterfall\n */', summary: 'Single-slot decision for the next FileSystem.writeText.', }, { name: 'llm/stream', mode: 'waterfall', signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable', + jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request arrives\n * deep-frozen (mutation throws): its content is a pure function of the\n * session log (the reconstructability RFC), so listeners read it, never\n * rewrite it. A hand-built one-shot (compaction summarize) is the\n * caller\'s own object and stays mutable here.\n * @mode waterfall\n */', summary: 'Waterfall around every streaming model call (retry, replay, routing).', }, { name: 'session/created', mode: 'emit', signature: '\'session/created\'(this: Scoped, session: Session): void', + jsDoc: '/**\n * Creation announcement during session publication. A synchronous throw vetoes and rolls\n * back with a paired disposal; detach requested during dispatch is deferred.\n * A returned-promise rejection is logged but cannot retroactively veto this\n * synchronous boundary.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners\n * receive only sessions entered through that agent\'s context.\n * @param session - the session just entered and announced.\n * @dshScopeScan unsupported\n * @mode emit\n */', summary: 'Creation announcement during session publication.', }, { name: 'session/disposed', mode: 'emit', signature: '\'session/disposed\'(this: Scoped, session: Session): void', + jsDoc: '/**\n * Emitted once when an announced session leaves the store, including\n * publication rollback, but never for an entry whose creation announcement\n * did not begin. Listener failures are logged and contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.\n * @param session - the session that is no longer live in the store.\n * @dshScopeScan unsupported\n * @mode emit\n */', summary: 'Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin.', }, { name: 'session/event', mode: 'emit', signature: '\'session/event\'(this: Scoped, session: Session, event: SessionEvent): void', + jsDoc: '/**\n * Post-commit, fire-and-forget append feed. The listener snapshot resolves\n * before the log push, but callbacks run after it; observer failures are\n * logged and contained without making the committed append fail.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners\n * receive only events from sessions entered through that agent\'s context.\n * @param session - the session whose log grew.\n * @param event - the appended event, exactly as recorded.\n * @dshScopeScan unsupported\n * @mode emit\n */', summary: 'Post-commit, fire-and-forget append feed.', }, { name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', + jsDoc: '/**\n * Awaited parallel durability checkpoint: every listener runs and the\n * caller awaits all of them, with no waterfall veto. Dispatch through\n * {@link SessionStore.flush}. Scope-filtered dispatch\n * (`@deepseek-ai/dsh-scope`) reuses the session\'s owner scope.\n * @param session - the session whose buffered events must reach durable storage.\n * @dshScopeScan unsupported\n * @mode parallel\n */', summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { name: 'subagent/end', mode: 'emit', signature: '\'subagent/end\'(this: Scoped, info: SubagentRunEndInfo): void', + jsDoc: '/**\n * A ready child settled. Scope-filtered dispatch uses the same delegating\n * parent carrier as `subagent/start`, so the lifecycle pair reaches the\n * same scoped audience.\n * @param info - the run identity and terminal outcome.\n * @dshScopeScan unsupported\n * @mode emit\n */', summary: 'A ready child settled.', }, { name: 'subagent/provider-added', mode: 'emit', signature: '\'subagent/provider-added\'(provider: SubagentProvider): void', + jsDoc: '/**\n * A provider became resolvable in the registry.\n * @param provider - the registered provider.\n * @mode emit\n */', summary: 'A provider became resolvable in the registry.', }, { name: 'subagent/provider-removed', mode: 'emit', signature: '\'subagent/provider-removed\'(name: string): void', + jsDoc: '/**\n * A provider left the registry. Accepted runs remain holder-owned.\n * @param name - the provider name that no longer resolves.\n * @mode emit\n */', summary: 'A provider left the registry.', }, { name: 'subagent/start', mode: 'emit', signature: '\'subagent/start\'(this: Scoped, info: SubagentRunInfo): void', + jsDoc: '/**\n * A provider established a ready child. For in-process providers,\n * `ctx.agents.get(info.id)` resolves during this notification.\n * Scope-filtered dispatch keys the carrier by the delegating parent, so a\n * parent-scoped listener observes only its own delegations. Paired with\n * `subagent/end`.\n * @param info - the provider and ready child identity.\n * @dshScopeScan unsupported\n * @mode emit\n */', summary: 'A provider established a ready child.', }, { name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', + jsDoc: '/**\n * Expert waterfall over the assembled sections, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, tools, and variables.', }, { name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', + jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */', summary: 'Emitted when any prompt provider changes.', }, { name: 'tools/change', mode: 'emit', signature: '\'tools/change\'(): void', + jsDoc: '/**\n * A tool was registered or unregistered, or a scoped restriction changed\n * (the available tool set changed — possibly for one scope only). An\n * UNFILTERED registry-subject notification, deliberately not scope-filtered\n * dispatch: a global change concerns every agent\'s next assembly, so a\n * scoped listener subscribing here sees every change, not just its own\n * scope\'s.\n * @mode emit\n */', summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).', }, { name: 'tools/execute', mode: 'waterfall', signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', + jsDoc: '/**\n * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns\n * a normalized result; wrappers may change only `exec.signal`, while call\n * identity remains immutable.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).\n * @mode waterfall\n */', summary: 'Around-dispatch waterfall for timeout, retry, or metrics.', }, { name: 'tools/post-execute', mode: 'waterfall', signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', + jsDoc: '/**\n * Accept, replace, enrich, or block a normalized dispatch result. `next()`\n * accepts it unchanged; thrown tools still reach this seam as errors.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the call that just ran (name, parsed arguments, caller agent).\n * @param result - the dispatch outcome a listener may accept, replace, or block.\n * @mode waterfall\n */', summary: 'Accept, replace, enrich, or block a normalized dispatch result.', }, { name: 'tools/pre-execute', mode: 'waterfall', signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', + jsDoc: '/**\n * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing\n * approval support turns `ask` into denial.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.\n * @param exec - the pending call (name, parsed arguments, caller agent).\n * @mode waterfall\n */', summary: 'Allow, deny, or ask before dispatch.', }, { name: 'tools/result', mode: 'emit', signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', + jsDoc: '/**\n * Observe the frozen, lossless-JSON final outcome. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.\n * @param exec - the execution object that traversed the pipeline.\n * @param result - a deep-frozen snapshot of the final returned result.\n * @mode emit\n */', summary: 'Observe the frozen, lossless-JSON final outcome.', }, { name: 'workflow/agent-end', mode: 'emit', signature: '\'workflow/agent-end\'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void', + jsDoc: '/**\n * One `agent()` call settled (clean result, child failure, or run\n * cancellation). Paired with {@link Events[\'workflow/agent-start\']} by\n * `agent.seq`, exactly once per started call on every stop path — on an\n * engine termination path (a worker killed past its grace) the end is\n * engine-synthesized with outcome `\'cancelled\'`.\n * @param info - the run\'s identity snapshot.\n * @param agent - the call identity plus its outcome.\n * @mode emit\n */', summary: 'One `agent()` call settled (clean result, child failure, or run cancellation).', }, { name: 'workflow/agent-start', mode: 'emit', signature: '\'workflow/agent-start\'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void', + jsDoc: '/**\n * One `agent()` call established a ready child run. Paired with\n * {@link Events[\'workflow/agent-end\']} by `agent.seq`. A call that never\n * receives a ready run from the provider emits neither\n * event in this pair.\n * @param info - the run\'s identity snapshot.\n * @param agent - the call\'s sequence number, label, phase, and child id.\n * @mode emit\n */', summary: 'One `agent()` call established a ready child run.', }, { name: 'workflow/end', mode: 'emit', signature: '\'workflow/end\'(info: WorkflowRunInfo, result: WorkflowResultInfo): void', + jsDoc: '/**\n * A workflow run settled (any stop reason). Fired when\n * {@link WorkflowRun.result} resolves. Paired with\n * {@link Events[\'workflow/start\']}.\n * @param info - the run\'s identity snapshot.\n * @param result - the outcome data (stop reason, error, agent count) —\n * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}).\n * @mode emit\n */', summary: 'A workflow run settled (any stop reason).', }, { name: 'workflow/log', mode: 'emit', signature: '\'workflow/log\'(info: WorkflowRunInfo, message: string): void', + jsDoc: '/**\n * The script emitted a narration line (a `log(message)` call).\n * @param info - the run\'s identity snapshot.\n * @param message - the logged message, verbatim.\n * @mode emit\n */', summary: 'The script emitted a narration line (a `log(message)` call).', }, { name: 'workflow/phase', mode: 'emit', signature: '\'workflow/phase\'(info: WorkflowRunInfo, title: string): void', + jsDoc: '/**\n * The script entered a phase (a `phase(title)` call) — progress grouping\n * for observers; no execution semantics.\n * @param info - the run\'s identity snapshot.\n * @param title - the phase title, verbatim.\n * @mode emit\n */', summary: 'The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics.', }, { name: 'workflow/start', mode: 'emit', signature: '\'workflow/start\'(info: WorkflowRunInfo): void', + jsDoc: '/**\n * A workflow run started — the script\'s meta block validated, the body\n * about to execute. Paired with {@link Events[\'workflow/end\']}.\n * @param info - the run\'s identity snapshot (id + meta).\n * @mode emit\n */', summary: 'A workflow run started — the script\'s meta block validated, the body about to execute.', }, ] @@ -532,7 +906,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentFactory', @@ -542,10 +916,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, - { - name: 'AgentId', - declaration: 'export type AgentId = Branded<\'AgentId\'>;', - }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n}', @@ -672,12 +1042,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CompactAgentContext', - declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n model?: string;\n };\n}', + declaration: 'export interface CompactAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', }, { name: 'CompactionResult', declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, + { + name: 'CompactionTrigger', + declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';', + }, { name: 'ConfinedArgv', declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}', @@ -700,7 +1074,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', @@ -852,7 +1226,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -1024,7 +1398,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', @@ -1124,7 +1498,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolDefinition', - declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', + declaration: 'export interface ToolDefinition extends ToolSchema {\n execute(args: unknown, exec: ToolRunContext): Promise;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n}', }, { name: 'ToolErrorInfo', @@ -1142,6 +1516,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolExecutionInput', declaration: 'export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n signal?: AbortSignal;\n}', }, + { + name: 'ToolExecutionMode', + declaration: 'export type ToolExecutionMode = {\n kind: \'parallel\';\n} | {\n kind: \'exclusive\';\n};', + }, { name: 'ToolExecutionResult', declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContexts?: HookContext[];\n meta?: unknown;\n}', diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index 321546c8c3..fd15d75624 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -12,9 +12,9 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { STATE_LABELS } from './fiber-state.ts' import { isPlugin, pluginName } from './guard.ts' +import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import { describeApi, describeDynamic, describeEvents, describePlugins, describeServices, describeTools } from './inspect.ts' -import { missingServices, mountDynamic } from './mount.ts' -import type { DynamicMount } from './mount.ts' +import { missingServices, mountDynamic, type DynamicMount } from './mount.ts' import { presentInspectCall, presentMountCall, presentUnmountCall } from './present.ts' import { createSandbox, evaluateMountCode } from './sandbox.ts' @@ -63,15 +63,23 @@ export function apply(ctx: Context, config: Config): void { + '`dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), ' + '`api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), ' + '`events` (every harness event with its dispatch mode and exact signature — pick listener targets here). ' - + 'Omit `what` to get all six sections.', + + 'Omit `what` to get all six sections. With `what:"api"` or `what:"events"`, pass an exact `name` ' + + 'to narrow to one service/event and include its original source JSDoc.', parameters: { what: { type: 'string', enum: ['services', 'plugins', 'tools', 'dynamic', 'api', 'events'], description: 'Limit the report to one section. Omit for all sections.', }, + name: { + type: 'string', + description: 'Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events".', + }, }, execute(args, exec): Promise<{ type: 'text'; text: string }[]> { + if (args.name !== undefined && args.what !== 'api' && args.what !== 'events') { + throw new Error('name is valid only with what:"api" or what:"events"') + } const sections: [heading: string, body: () => string[]][] = [ ['services', () => describeServices(ctx)], ['plugins', () => describePlugins(ctx)], @@ -79,8 +87,8 @@ export function apply(ctx: Context, config: Config): void { // globals absent — "what you can call", not the global registry. ['tools', () => describeTools(ctx, exec.agent)], ['dynamic', () => describeDynamic(ctx, mounts)], - ['api', () => describeApi(ctx)], - ['events', () => describeEvents()], + ['api', () => describeApi(ctx, SERVICE_API, INHERITED_CTX_API, TYPE_API, args.name)], + ['events', () => describeEvents(EVENT_API, args.name)], ] const selected = sections.filter(([heading]) => args.what === undefined || args.what === heading) const text = selected diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index b483b13712..7196f370ce 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -1,7 +1,8 @@ /** * Read-only renderers over the live runtime for `cordis_inspect`: the service list, the flat * plugin list, the registered tools, the dynamic-mount table (with per-mount provides/waits), - * and the catalog-backed `api` / `events` sections. + * and the catalog-backed `api` / `events` sections. Exact-name lookups add the + * original source JSDoc without inflating the default reports. * @module @deepseek-ai/dsh-tool-cordis/inspect */ @@ -126,6 +127,18 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn return [...included.values()].sort((a, b) => a.name.localeCompare(b.name)) } +/** Render one catalogued service, optionally including source-owned method JSDoc. */ +function serviceLines(entry: ServiceApiEntry, detailed: boolean): string[] { + const lines = [`- ${entry.key} — ${entry.summary}`] + for (const method of entry.methods) { + if (detailed) { + for (const docLine of method.jsDoc.split('\n')) lines.push(` ${docLine}`) + } + lines.push(` ${method.signature}`) + } + return lines +} + /** * Render the generated catalog against the live runtime: live catalogued services with methods, * uncatalogued live services with owners, absent loadable services, referenced type shapes, and @@ -134,6 +147,7 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn * @param api - generated service entries, replaceable in tests. * @param inherited - inherited `ctx` entries, replaceable in tests. * @param types - public type shapes, replaceable in tests. + * @param name - exact live service key whose methods should include original JSDoc; omitted for the compact catalog. * @returns the section lines. */ export function describeApi( @@ -141,25 +155,33 @@ export function describeApi( api: readonly ServiceApiEntry[] = SERVICE_API, inherited: readonly InheritedApiEntry[] = INHERITED_CTX_API, types: readonly TypeApiEntry[] = TYPE_API, + name?: string, ): string[] { const live = new Map() for (const impl of liveImpls(ctx)) live.set(impl.name, impl.fiber.name) const lines: string[] = [] const liveMethodTexts: string[] = [] - for (const entry of api) { - if (!live.has(entry.key)) continue - lines.push(`- ${entry.key} — ${entry.summary}`) + let selected = api.filter(entry => live.has(entry.key)) + if (name !== undefined) { + const entry = api.find(candidate => candidate.key === name) + if (!entry) throw new Error(`no catalogued service named "${name}"`) + if (!live.has(name)) throw new Error(`catalogued service "${name}" is not running`) + selected = [entry] + } + for (const entry of selected) { + lines.push(...serviceLines(entry, name !== undefined)) for (const method of entry.methods) { - lines.push(` ${method}`) - liveMethodTexts.push(method) + liveMethodTexts.push(method.signature) } } - const catalogued = new Set(api.map(entry => entry.key)) - for (const [name, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) { - if (!catalogued.has(name)) lines.push(`- ${name} (provided by ${fiber}, no catalog entry)`) + if (name === undefined) { + const catalogued = new Set(api.map(entry => entry.key)) + for (const [liveName, fiber] of [...live].sort(([a], [b]) => a.localeCompare(b))) { + if (!catalogued.has(liveName)) lines.push(`- ${liveName} (provided by ${fiber}, no catalog entry)`) + } + const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key) + if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`) } - const notRunning = api.filter(entry => !live.has(entry.key)).map(entry => entry.key) - if (notRunning.length > 0) lines.push(`not running (loadable services with no live provider): ${notRunning.join(', ')}`) const shapes = typeClosure(liveMethodTexts, types) if (shapes.length > 0) { lines.push('type shapes (referenced by the signatures above — read these before assuming a field is a string):') @@ -167,8 +189,10 @@ export function describeApi( for (const declLine of shape.declaration.split('\n')) lines.push(` ${declLine}`) } } - lines.push('inherited ctx API:') - for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`) + if (name === undefined) { + lines.push('inherited ctx API:') + for (const entry of inherited) lines.push(`- ${entry.name} — ${entry.summary}`) + } return lines } @@ -176,13 +200,24 @@ export function describeApi( * The `events` section: every harness event with its dispatch mode, one-line * summary, and exact signature, closed by the waterfall caution. * @param events - the event catalog (the generated one by default; injectable for tests). + * @param name - exact event name whose signature should include original JSDoc; omitted for the compact catalog. * @returns the section lines. */ -export function describeEvents(events: readonly EventApiEntry[] = EVENT_API): string[] { - const lines = events.flatMap(event => [ - `- ${event.name} [${event.mode}] — ${event.summary}`, - ` ${event.signature}`, - ]) +export function describeEvents(events: readonly EventApiEntry[] = EVENT_API, name?: string): string[] { + let selected = events + if (name !== undefined) { + const event = events.find(candidate => candidate.name === name) + if (!event) throw new Error(`no catalogued event named "${name}"`) + selected = [event] + } + const lines = selected.flatMap((event) => { + const entry = [`- ${event.name} [${event.mode}] — ${event.summary}`] + if (name !== undefined) { + for (const docLine of event.jsDoc.split('\n')) entry.push(` ${docLine}`) + } + entry.push(` ${event.signature}`) + return entry + }) lines.push('waterfall listeners receive a trailing next() and MUST call it to delegate — returning without next() vetoes the chain.') return lines } diff --git a/packages/cordis/tool-cordis/src/present.ts b/packages/cordis/tool-cordis/src/present.ts index 614b070193..e13570cf43 100644 --- a/packages/cordis/tool-cordis/src/present.ts +++ b/packages/cordis/tool-cordis/src/present.ts @@ -15,11 +15,12 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools' * @param args - the validated call arguments. * @returns the generic card the ACP bridge renders. */ -export function presentInspectCall(args: { what?: string }): GenericCallView { +export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView { + const target = args.name === undefined ? args.what : `${args.what}: ${args.name}` return { card: 'generic', kind: 'read', - title: args.what === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${args.what}`, + title: target === undefined ? 'Inspect cordis runtime' : `Inspect cordis runtime: ${target}`, } } diff --git a/packages/cordis/tool-cordis/tests/inspect.spec.ts b/packages/cordis/tool-cordis/tests/inspect.spec.ts index 1a8c39467a..4d986d4f3e 100644 --- a/packages/cordis/tool-cordis/tests/inspect.spec.ts +++ b/packages/cordis/tool-cordis/tests/inspect.spec.ts @@ -64,6 +64,24 @@ describe('cordis_inspect', () => { // The inherited ctx surface closes the section. expect(report).toContain('inherited ctx API:') expect(report).toContain('- ctx.effect — ') + // The broad report stays compact; exact-name lookup owns full JSDoc. + expect(report).not.toContain('/**') + expect(report).not.toContain('@param definition') + }) + + it('adds original method JSDoc only for an exact live api name', async () => { + const ctx = await setup() + const report = text(await call(ctx, 'cordis_inspect', { what: 'api', name: 'tools' })) + expect(report).toContain('## api') + expect(report).toContain('- tools — Tool registry and execution pipeline.') + expect(report).toContain('/**') + expect(report).toContain('Register globally or in the calling agent scope.') + expect(report).toContain('@param definition - the tool schema') + expect(report).toContain('@returns the exact disposer') + expect(report).toContain('register(definition: ToolDefinition)') + expect(report).toContain('type shapes (referenced by the signatures above') + expect(report).not.toContain('not running (loadable services') + expect(report).not.toContain('inherited ctx API:') }) it('renders the events section with mode badges, signatures, and the waterfall caution', async () => { @@ -73,6 +91,39 @@ describe('cordis_inspect', () => { expect(report).toContain('- tools/pre-execute [waterfall]') expect(report).toMatch(/'agent\/status'\(/) expect(report).toContain('returning without next() vetoes the chain') + expect(report).not.toContain('/**') + expect(report).not.toContain('@mode waterfall') + }) + + it('adds original event JSDoc only for an exact event name', async () => { + const ctx = await setup() + const report = text(await call(ctx, 'cordis_inspect', { what: 'events', name: 'tools/pre-execute' })) + expect(report).toContain('## events') + expect(report).toContain('- tools/pre-execute [waterfall]') + expect(report).toContain('/**') + expect(report).toContain('Allow, deny, or ask before dispatch.') + expect(report).toContain('@param exec - the pending call') + expect(report).toContain('@mode waterfall') + expect(report).not.toContain('- tools/change [emit]') + }) + + it('fails loud for incompatible, unknown, and non-running names', async () => { + const ctx = await setup() + const incompatible = await call(ctx, 'cordis_inspect', { what: 'tools', name: 'tools' }) + expect(incompatible.isError).toBe(true) + expect(text(incompatible)).toContain('name is valid only with what:"api" or what:"events"') + + const unknownService = await call(ctx, 'cordis_inspect', { what: 'api', name: 'not-a-service' }) + expect(unknownService.isError).toBe(true) + expect(text(unknownService)).toContain('no catalogued service named "not-a-service"') + + const nonRunning = await call(ctx, 'cordis_inspect', { what: 'api', name: 'bash' }) + expect(nonRunning.isError).toBe(true) + expect(text(nonRunning)).toContain('catalogued service "bash" is not running') + + const unknownEvent = await call(ctx, 'cordis_inspect', { what: 'events', name: 'not/an-event' }) + expect(unknownEvent.isError).toBe(true) + expect(text(unknownEvent)).toContain('no catalogued event named "not/an-event"') }) }) @@ -102,7 +153,11 @@ describe('inspect renderers (direct)', () => { it('describeApi omits the not-running line and type shapes when nothing applies', async () => { const ctx = await setup() - const lines = describeApi(ctx, [{ key: 'tools', summary: 'The registry.', methods: ['register(x): void'] }], [], []) + const lines = describeApi(ctx, [{ + key: 'tools', + summary: 'The registry.', + methods: [{ signature: 'register(x): void', jsDoc: '/** Register x. */' }], + }], [], []) expect(lines[0]).toBe('- tools — The registry.') expect(lines[1]).toBe(' register(x): void') expect(lines.join('\n')).not.toContain('not running') diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 9823e119b5..d68f6be349 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -24,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -44,7 +45,7 @@ describe('cordis tools through the agent loop', () => { textResponse('Done.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-cordis'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-cordis'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 09011c77de..2ddee8dbca 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -175,9 +175,13 @@ describe('cordis_mount', () => { // The registered schema is canonical JSON Schema derived from the DSL: // the required array survived, integer became number, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! - const parameters = schema.parameters as { properties: Record; required?: string[] } + const parameters = schema.parameters as { + properties: Record + required?: string[] + } expect(parameters.required).toEqual(['text']) expect(parameters.properties.count!.type).toBe('number') + expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true) diff --git a/packages/cordis/tool-cordis/tests/present.spec.ts b/packages/cordis/tool-cordis/tests/present.spec.ts index d8f380439f..ed45fd1518 100644 --- a/packages/cordis/tool-cordis/tests/present.spec.ts +++ b/packages/cordis/tool-cordis/tests/present.spec.ts @@ -11,6 +11,11 @@ describe('presenters', () => { it('cordis_inspect renders a generic read card titled with the section', () => { expect(presentInspectCall({})).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime' }) expect(presentInspectCall({ what: 'api' })).toEqual({ card: 'generic', kind: 'read', title: 'Inspect cordis runtime: api' }) + expect(presentInspectCall({ what: 'events', name: 'tools/change' })).toEqual({ + card: 'generic', + kind: 'read', + title: 'Inspect cordis runtime: events: tools/change', + }) }) it('cordis_mount renders a generic execute card carrying the code as raw input', () => { @@ -33,6 +38,9 @@ describe('presenters', () => { kind: 'read', title: 'Inspect cordis runtime: tools', }) + expect(ctx.tools.get('cordis_inspect')!.presentCall!({ what: 'api', name: 'tools' })).toMatchObject({ + title: 'Inspect cordis runtime: api: tools', + }) expect(ctx.tools.get('cordis_mount')!.presentCall!({ code: 'return 1' })).toMatchObject({ kind: 'execute' }) expect(ctx.tools.get('cordis_unmount')!.presentCall!({ id: 'dyn-2' })).toMatchObject({ title: 'Unmount dyn-2' }) // Soft validation: presenter args that fail the schema render as no card, never a throw. diff --git a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts index 8953b5da94..d305a92883 100644 --- a/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/cordis/tool-cordis/tests/tool-cordis.spec.ts @@ -32,8 +32,9 @@ describe('tool registration', () => { const names = ctx.tools.schemas().map(schema => schema.name) expect(names).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) const inspect = ctx.tools.schemas().find(schema => schema.name === 'cordis_inspect')! - const props = (inspect.parameters as { properties: Record }).properties + const props = (inspect.parameters as { properties: Record }).properties expect(props.what?.enum).toEqual(['services', 'plugins', 'tools', 'dynamic', 'api', 'events']) + expect(props.name?.type).toBe('string') }) }) diff --git a/packages/core/README.md b/packages/core/README.md index d2d0c60fda..fe98a78c22 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,11 +8,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `session/` | Event-sourced session log + in-memory store | `ctx.sessions` | | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | -| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` | +| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. -`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable. +`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable. The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 8d5ea84226..e2c7723185 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -Concrete `ReactLoopAgent` implementation and loop driver. +THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -8,16 +8,18 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md). +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. -Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach. +The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. + +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -29,6 +31,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ```ts interface Config { + maxParallelToolCalls?: number // default 10; 1 is serial agents: Array<{ id: string // required provider?: string @@ -39,27 +42,27 @@ interface Config { } ``` -Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. +Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. -### Exported concrete class +### Internal concrete driver -- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. - -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. +The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. +The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. -Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. +Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Cancellation clears pending work and aborts the current step without leaking to the next prompt; undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush. + +Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. ### What belongs to plugins Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) -- Compaction: `agent/pre-step` +- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error` - Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection. - Persistence: `session/event` + `session/flush` @@ -69,19 +72,49 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p ### Complete conversation request -**What the model sees**: For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose. +#### What the model sees -**Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. +For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose. + +#### Token effect + +System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. + +#### KV Cache effect + +Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token. ### Retained message history -**What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded. +#### What the model sees -**Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step. +Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded. + +#### Token effect + +Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step. + +#### KV Cache effect + +Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token. + +### Undispatched calls after cancellation + +#### What the model sees + +If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`. + +#### Token effect + +One fixed error result per skipped call remains in history until compaction shadows it. + +#### KV Cache effect + +Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work -- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`). -- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. +- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)). +- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. - **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 9d7e914a5d..61b661c082 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,11 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -54,15 +54,20 @@ export interface PreparedReactLoopAgent { * @param id - the concrete agent identity. * @param options - loop options for the agent. * @param session - the prepared session the agent will own. + * @param maxParallelToolCalls - resolved in-flight cap for this agent. * @returns the agent and closures bound only to that exact instance. */ export function prepareReactLoopAgent( - ctx: Context, id: AgentId, options: AgentOptions, session: Session, + ctx: Context, + id: SessionId, + options: AgentOptions, + session: Session, + maxParallelToolCalls: number, ): PreparedReactLoopAgent { if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) } - const agent = new ReactLoopAgent(ctx, id, options, session) + const agent = new ReactLoopAgent(ctx, id, options, session, maxParallelToolCalls) claimedDriverSessions.add(session) const dispose = () => agent[stopDriver]() return { @@ -143,6 +148,8 @@ export class ReactLoopAgent implements Agent { * the `disposed` transition fires and leave the promise hanging. */ private idleWaiters: (() => void)[] = [] + /** Maximum parallel-safe calls allowed in one step. */ + private readonly maxParallelToolCalls: number /** * Durability checkpoints started by idle {@link inject} calls. `inject()` is * synchronous, so it cannot await them itself; the driver disposer drains @@ -156,10 +163,12 @@ export class ReactLoopAgent implements Agent { constructor( private loopCtx: Context, - public readonly id: AgentId, + public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, + maxParallelToolCalls: number, ) { + this.maxParallelToolCalls = maxParallelToolCalls const { promise, resolve } = Promise.withResolvers() this.disposed = promise this.resolveDisposed = resolve @@ -378,8 +387,9 @@ export class ReactLoopAgent implements Agent { [startDriver](): void { if (this._status === 'disposed') return this.driverStarted = true - this.done = runLoop(this.loopCtx, this, { + this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, { inbox: this.#inbox, + maxParallelToolCalls: this.maxParallelToolCalls, setStatus: (status) => { this.setStatus(status) }, setAbort: controller => void (this.currentAbort = controller), disposed: this.disposed, @@ -390,7 +400,7 @@ export class ReactLoopAgent implements Agent { withToolBatch: run => this.withToolBatch(run), // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, - }) + })) } /** diff --git a/packages/core/agent-loop/src/constants.ts b/packages/core/agent-loop/src/constants.ts new file mode 100644 index 0000000000..3f5510967a --- /dev/null +++ b/packages/core/agent-loop/src/constants.ts @@ -0,0 +1,6 @@ +/** Shared agent-loop scheduler defaults. + * @module dsh-agent-loop/constants + */ + +/** Default maximum in-flight parallel-safe calls per agent step. */ +export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index a4d23671e7..2a77afc983 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -12,9 +12,9 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { + Agent, AgentFactory, AgentHandle, - AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, @@ -32,8 +32,7 @@ import { ReactLoopAgent, } from './agent.ts' import type { PreparedReactLoopAgent } from './agent.ts' - -export { ReactLoopAgent } from './agent.ts' +import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ @@ -42,10 +41,21 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) +/** Render an arbitrary thrown value without letting coercion escape containment. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Factory-level ownership of every preparing or live transaction. */ class FactoryOwnership { private accepting = true + private readonly inactive = Promise.withResolvers() private transactions = new Set() + private startupTasks = new Set>() constructor(private readonly fiber: Context['fiber']) {} @@ -58,21 +68,44 @@ class FactoryOwnership { return () => { this.transactions.delete(transaction) } } + /** Join config startup work that begins before an agent transaction exists. */ + trackStartup(task: Promise): void { + this.startupTasks.add(task) + const forget = () => { this.startupTasks.delete(task) } + void task.then(forget, forget) + } + + /** Resolve `task`, or stop waiting when factory teardown begins. */ + async waitWhileActive(task: Promise): Promise { + await Promise.race([task, this.inactive.promise]) + } + async dispose(): Promise { this.accepting = false + this.inactive.resolve() const reason = new Error('agent loop is not active') - await Promise.all( - [...this.transactions].map(transaction => transaction.disposeForFactory(reason)), - ) + await Promise.all([ + ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ...this.startupTasks, + ]) } } /** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: AgentId, signal: AbortSignal): Error { +function signalAbortError(id: SessionId, signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } +/** Resolve the deployment-wide scheduler cap at the owning config boundary. */ +function resolveMaxParallelToolCalls(value: number | undefined): number { + const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS + if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) { + throw new Error('maxParallelToolCalls must be a positive integer') + } + return maxParallelToolCalls +} + /** * Caller-owned create/resume transaction through rollback-covered publication * and quiescent teardown. Resources remain private until the final registry @@ -105,7 +138,7 @@ class AgentCreationTransaction { private readonly loopCtx: Context, private readonly ownerCtx: Context, private readonly ownership: FactoryOwnership, - readonly id: AgentId, + readonly id: SessionId, signal?: AbortSignal, ) { ownerCtx.fiber.assertActive() @@ -163,13 +196,13 @@ class AgentCreationTransaction { } /** Construct the driver and scope, then install their complete ordered lifecycle. */ - prepare(options: AgentOptions, session: Session): ReactLoopAgent { + prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() const gate = Promise.withResolvers() this.preparing = gate.promise try { this.session = session - const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session) + const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session, maxParallelToolCalls) this.driver = driver const agent = driver.agent const scope = createScope(this.loopCtx, agent) @@ -227,7 +260,7 @@ class AgentCreationTransaction { this.publishing = true try { this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent) + this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) agent.ctx.sessions.announce(session) this.assertActive() @@ -316,14 +349,35 @@ declare module 'cordis' { interface Context { agentLoop: AgentLoop } + interface Events { + /** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ + 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + } } -/** Plugin configuration for declarative startup agents. */ +export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } + +/** Agent-loop plugin configuration. */ export interface Config { + /** + * Maximum parallel-safe calls in flight per agent step. `1` is serial; + * omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -331,14 +385,34 @@ export interface Config { })[] } -/** Concrete ReactLoopAgent factory and driver service. */ +/** Reject self-contained identity conflicts before any configured agent starts. */ +function validateConfiguredAgents(agents: Config['agents']): void { + const exactIdentities = new Map() + for (const { id, sessionId, resumeSessionId } of agents) { + const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== '' + if (sessionId !== undefined && hasResumeId) { + throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) + } + const exactIdentity = hasResumeId ? resumeSessionId : sessionId + if (exactIdentity === undefined) continue + const firstId = exactIdentities.get(exactIdentity) + if (firstId !== undefined) { + throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`) + } + exactIdentities.set(exactIdentity, id) + } +} + +/** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] /** Runtime schema for declarative agents. */ static Config = z.object({ + maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS), agents: z.array(z.object({ id: z.string().required(), + sessionId: z.string().min(1), provider: z.string(), model: z.string(), cwd: z.string(), @@ -347,11 +421,15 @@ export class AgentLoop extends Service implements AgentFactory { }) as unknown as z private readonly ownership: FactoryOwnership + /** Resolved concurrency cap for every driver created by this factory. */ + private readonly maxParallelToolCalls: number /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') + validateConfiguredAgents(config.agents) + this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') @@ -360,19 +438,28 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { + const meta = cwd === undefined ? {} : { cwd } if (resumeSessionId === undefined || resumeSessionId === '') { - this.create(id, options, cwd === undefined ? {} : { cwd }) + const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`) + const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence') + if (persistence === undefined) { + this.create(configuredId, options, meta) + } else { + const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { + this.reportConfiguredStartupFailure(id, 'restore', configuredId, error) + }) + this.ownership.trackStartup(startup) + } continue } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { - agentId: id, resumeSessionId, agentOptions: options, }).catch((error: unknown) => { - ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) + this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error) }) }) return fiber.dispose @@ -380,21 +467,84 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Report a contained declarative-start failure to identity-bound consumers. */ + private reportConfiguredStartupFailure( + configId: string, + action: 'restore' | 'resume', + sessionId: SessionId, + error: unknown, + ): void { + if (!this.ownership.isActive()) return + this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) + const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((listenerError: unknown) => { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`) + }) + } catch (listenerError: unknown) { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`) + } + } + } + + /** Restore a materialized exact config identity on remount, or create it on first use. */ + private async restoreOrCreateConfigured( + ownerCtx: Context, + persistence: SessionPersistence, + sessionId: SessionId, + agentOptions: AgentOptions, + meta: Pick, + ): Promise { + await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) + if (!this.ownership.isActive()) return + const exists = (await persistence.list()).some(header => header.id === sessionId) + if (!this.ownership.isActive()) return + if (exists) { + await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) + return + } + this.create(sessionId, agentOptions, meta) + } + + /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */ + private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise { + const current = ownerCtx.agents.get(sessionId) + if (current?.status !== 'disposed') return + + const released = Promise.withResolvers() + const checkReleased = (): void => { + if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) { + released.resolve() + } + } + const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) + try { + checkReleased() + await this.ownership.waitWhileActive(released.promise) + } finally { + disposeAgentListener() + disposeSessionListener() + } + } + /** - * Create an agent on a fresh per-run session, owned by the accessing fiber. - * Constructor-driven config calls use the loop fiber itself. - * @param id - agent registry id. + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - const session = loopCtx.sessions.prepare(sessionId, { meta }) - const agent = transaction.prepare(options, session) + const session = loopCtx.sessions.prepare(id, { meta }) + const agent = transaction.prepare(options, session, this.maxParallelToolCalls) transaction.publish('startup') return agent } catch (error: unknown) { @@ -412,11 +562,12 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { + const agentOptions = options.agentOptions ?? {} const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.sessionId, options.signal, ) try { @@ -424,7 +575,7 @@ export class AgentLoop extends Service implements AgentFactory { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('startup') @@ -456,11 +607,12 @@ export class AgentLoop extends Service implements AgentFactory { persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { + const agentOptions = options.agentOptions ?? {} const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.resumeSessionId, options.signal, ) try { @@ -475,7 +627,7 @@ export class AgentLoop extends Service implements AgentFactory { ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('resume') diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index ae5850ee3b..0012b504cd 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -8,9 +8,9 @@ import type { Context } from 'cordis' import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' import { isDeepStrictEqual } from 'node:util' -import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm' +import { BlockAssembler, HarnessError, assertNever, deepFreeze, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm' import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' -import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent' import { canonicalHeader } from '@deepseek-ai/dsh-session' import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session' import { createTransmissionLog, recordRequestHeader } from './request-log.ts' @@ -18,27 +18,32 @@ import type { TransmissionLog } from './request-log.ts' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { ReactLoopAgent } from './agent.ts' +import { executeToolCalls } from './tool-calls.ts' import type { Inbox } from './inbox.ts' -/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */ -type CodedError = Error & { code?: string } - /** Normalize thrown values while preserving an existing error code. */ -function toError(error: unknown): CodedError { +function toError(error: unknown): RequestError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } +/** Distinguishes final model-request failures from failures in later step processing. */ +class TerminalModelRequestFailure extends Error { + constructor(readonly requestError: RequestError) { + super(requestError.message, { cause: requestError }) + this.name = 'TerminalModelRequestFailure' + } +} + /** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ -function finishError(finish: FinishReason): CodedError | undefined { +function finishError(finish: FinishReason): RequestError | undefined { switch (finish.kind) { case 'error': { - const error: CodedError = new Error(finish.message) + const error: RequestError = new Error(finish.message) if (finish.code !== undefined) error.code = finish.code return error } case 'aborted': { - const error: CodedError = new Error('model stream aborted') + const error: RequestError = new Error('model stream aborted') error.code = 'ABORTED' return error } @@ -52,7 +57,7 @@ function finishError(finish: FinishReason): CodedError | undefined { * Build the `{ message, code? }` part of an error payload, omitting the * `code` key entirely when absent (exactOptionalPropertyTypes-correct). */ -function errorData(err: CodedError): { message: string; code?: string } { +function errorData(err: RequestError): { message: string; code?: string } { return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } } @@ -73,6 +78,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ readonly inbox: Inbox + /** Maximum parallel-safe calls allowed in one step. */ + readonly maxParallelToolCalls: number setStatus(status: 'idle' | 'running'): void setAbort(controller: AbortController | undefined): void /** Resolves when the agent is disposed — unblocks the idle wait. */ @@ -92,12 +99,17 @@ export interface LoopHandle { /** * Drive queued batches as durable turns until disposal. Plugin failures end the - * current turn without terminating the driver. - * @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through. - * @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options). + * current turn without terminating the driver. The caller establishes the + * `ctx.agents.withInitiator()` boundary before entry; package-private + * orchestration recovers that exact Agent and captures its Session locally. + * @param ctx - the plugin context the loop reaches its initiating Agent, + * events (agent/…, session/flush), and services (systemPrompt, llm, tools) + * through. * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads. + * @throws when no initiating Agent is active. */ -export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { +export async function runLoop(ctx: Context, handle: LoopHandle): Promise { + const agent = ctx.agents.requireInitiator() // Per-instance prefix and request-header state; conversation history remains in the session log. const transmission = createTransmissionLog() @@ -135,7 +147,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { - terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission) + terminalStopped = await runTurn(ctx, events, handle, turn, transmission) } catch (error: unknown) { // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) @@ -158,9 +170,17 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } async function runTurn( - ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog, + ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog, ): Promise { + const agent = ctx.agents.requireInitiator() const { session } = agent + const drainSteering = (): boolean => { + const messages = handle.inbox.drainSteering() + for (const message of messages) { + session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) + } + return messages.length > 0 + } // Drain before opening the turn, but append only after `turn/start`. const queued = handle.inbox.drainQueued() @@ -171,6 +191,7 @@ async function runTurn( let reason: TurnEndReason = { kind: 'completed' } let step = 0 + let requestRetryAttempt = 0 let stepOpen = false let errorReported = false let terminalStopped = false @@ -183,7 +204,7 @@ async function runTurn( } // Record the durable turn failure once and contain the live error notification. - const failTurn = (err: CodedError): void => { + const failTurn = (err: RequestError): void => { if (errorReported) return errorReported = true reason = { kind: 'error', step, ...errorData(err) } @@ -259,7 +280,7 @@ async function runTurn( // Steering from the previous round's continuation listeners joins before // the request. - drainSteering(agent, handle.inbox, turn) + drainSteering() // The step's AbortController exists BEFORE any async pre-step work so a // dispose() or cancel() — in a synchronous turn-start listener or an @@ -269,7 +290,7 @@ async function runTurn( const abort = new AbortController() handle.setAbort(abort) - // Assemble once before pre-step so pressure checks and the request share the same prompt. + // Assemble once before pre-step so listener work and the request share one prompt value. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) @@ -280,9 +301,9 @@ async function runTurn( break } - // Compose the request-only prefix once per loop instance before pressure - // checks. It precedes all derived history and is recorded only in the - // request header, not as session history. + // Compose the request-only prefix once per loop instance before the first + // request boundary. It precedes all derived history and is recorded only + // in the request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -299,8 +320,8 @@ async function runTurn( transmission.sessionPrefix = deepFreeze(structuredClone(composed)) } - // Await surface mutations outside the step; pressure checks receive the pending prefix. - await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal) + // Await surface mutations outside the step before snapshotting history. + await events.serial('agent/pre-step', turn, step, abort.signal) // Interruption landing during the pre-step seam: do not open an empty step. if (handle.isCancelled() || handle.isDisposed()) { @@ -330,14 +351,69 @@ async function runTurn( break } - let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error } + let stepOutcome: + | { hadToolCalls: boolean; finish: FinishReason } + | { requestError: RequestError } + | { error: RequestError } try { stepOutcome = await runStep( - ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) + ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal) } catch (error: unknown) { - stepOutcome = { error: toError(error) } - } finally { + if (error instanceof TerminalModelRequestFailure) { + stepOutcome = { requestError: error.requestError } + } else { + stepOutcome = { error: toError(error) } + } + } + + if ('requestError' in stepOutcome) { + // Recovery observes a balanced failed step and the original provider + // error while the failed step's signal remains the active owner. + closeStep() + if (handle.isDisposed() || abort.signal.aborted) { + handle.setAbort(undefined) + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + break + } + + const defaultDecision: RequestErrorDecision = { action: 'fail' } + let recoveryDecision: RequestErrorDecision = defaultDecision + try { + recoveryDecision = await events.waterfall( + 'agent/request-error', turn, step, stepOutcome.requestError, + requestRetryAttempt, abort.signal, + () => Promise.resolve(defaultDecision), + ) + } catch (recoveryError: unknown) { + ctx.logger.warn( + `agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${toError(recoveryError).message}`, + ) + } handle.setAbort(undefined) + + // Cancellation and disposal always win over either a recovery decision + // or a recovery-listener failure. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (handle.isDisposed() || abort.signal.aborted) { + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + break + } + switch (recoveryDecision.action) { + case 'retry': + requestRetryAttempt += 1 + continue + case 'fail': + failTurn(stepOutcome.requestError) + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(recoveryDecision, 'agent request-error decision') + } + break } if ('error' in stepOutcome) { @@ -345,7 +421,9 @@ async function runTurn( // runLoop re-enqueues it as a queued message, so an abort-then-steer // starts a fresh turn instead of being silently consumed. closeStep() + handle.setAbort(undefined) const { error } = stepOutcome + /* v8 ignore next -- narrow race: disposal while non-request step work throws. */ if (handle.isDisposed()) { reason = { kind: 'disposed' } } else if (abort.signal.aborted) { @@ -357,14 +435,47 @@ async function runTurn( break } + requestRetryAttempt = 0 + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason // Steering that arrived during streaming/tool execution. - const steered = drainSteering(agent, handle.inbox, turn) + const steered = drainSteering() + + try { + await events.serial('agent/post-step', turn, step, abort.signal) + } catch (error: unknown) { + stepOutcome = { error: toError(error) } + } + + if ('error' in stepOutcome) { + closeStep() + handle.setAbort(undefined) + /* v8 ignore next -- narrow race: disposal while a post-step listener throws. */ + if (handle.isDisposed()) { + reason = { kind: 'disposed' } + } else if (abort.signal.aborted) { + /* v8 ignore next -- signal.reason always set by cancellation or disposal. */ + reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') } + } else { + failTurn(stepOutcome.error) + } + break + } + + if (handle.isDisposed() || abort.signal.aborted) { + reason = handle.isDisposed() + ? { kind: 'disposed' } + : { kind: 'aborted', reason: String(abort.signal.reason) } + closeStep() + handle.setAbort(undefined) + break + } closeStep() + handle.setAbort(undefined) const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' } let decision: ContinuationDecision @@ -451,15 +562,6 @@ async function runTurn( return terminalStopped } -/** Drain the steering queue into the session. Returns whether any arrived. */ -function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean { - const messages = inbox.drainSteering() - for (const message of messages) { - agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) - } - return messages.length > 0 -} - /** * Run one committed step: transform call config, log the request header, build * the request from the cached prefix plus the step-boundary snapshot, stream and @@ -469,7 +571,6 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole async function runStep( ctx: Context, events: AgentEventDispatch, - agent: ReactLoopAgent, handle: LoopHandle, turn: number, step: number, @@ -479,6 +580,7 @@ async function runStep( transmission: TransmissionLog, signal: AbortSignal, ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { + const agent = ctx.agents.requireInitiator() const { session, options } = agent // Seed the first request from agent options and later requests from the logged header; @@ -523,27 +625,65 @@ async function runStep( // --- Model call (streaming-first; raw chunks are the replay record) --- const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - for await (const chunk of ctx.llm.stream(request)) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) - chunkSeqs.push(chunkEvent.seq) - assembler.push(chunk) + const stream = ctx.llm.stream(request) + try { + for await (const chunk of stream) { + /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ + if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) + chunkSeqs.push(chunkEvent.seq) + assembler.push(chunk) + } + } catch (error: unknown) { + if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error) + throw error } // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) - if (stepError) throw stepError + if (stepError) throw new TerminalModelRequestFailure(stepError) + + const recordAssistantMessage = ( + assembledContent: ContentBlock[], + message: Message, + preserveReplayState = true, + ): void => { + session.append( + 'assistant/message', + { + turn, + step, + content: message.content, + provenance: assistantProvenance( + header.config, + assembler.replayState, + preserveReplayState && isDeepStrictEqual(message.content, assembledContent), + ), + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + ) + } + + // A rejected result still records the successful provider call without retaining rejected output. + const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise => { + try { + return await events.waterfall( + 'agent/step-result', turn, step, message, () => Promise.resolve(message), + ) + } catch (error: unknown) { + recordAssistantMessage(assembledContent, { ...message, content: [] }, false) + throw error + } + } if (assembler.finish.kind === 'max-tokens') { const assembled = assembler.message() const assembledContent = structuredClone(assembled.content) let message: Message = withoutToolCalls(assembled) - message = withoutToolCalls(await processStepResult( - events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, - )) + message = withoutToolCalls(await processStepResult(assembledContent, message)) // Preserve usage even when max-token truncation produced no content. - recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs) + recordAssistantMessage(assembledContent, message) return { hadToolCalls: false, finish: assembler.finish } } @@ -551,122 +691,23 @@ async function runStep( const assembled = assembler.message() const assembledContent = structuredClone(assembled.content) let message: Message = assembled - message = await processStepResult( - events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, - ) + message = await processStepResult(assembledContent, message) // Every successful call records its completion anchor, including explicit // empty chunk provenance for a contentless, usage-less provider response. - recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs) + recordAssistantMessage(assembledContent, message) - // Tool execution stays sequential; recheck abort around each normalized result. + // Dispatch may overlap; policy, durable results, and result context stay model-ordered. const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish } return handle.withToolBatch(async (acceptContext) => { - for (const call of toolCalls) { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) - let parsedArguments: unknown - try { - parsedArguments = call.arguments ? JSON.parse(call.arguments) : {} - } catch { - parsedArguments = call.arguments - } - // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; - // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. - const result = await ctx.tools.execute({ - callId: call.id, - name: call.name, - arguments: parsedArguments, - agent, - signal, - }) - session.append('tool/result', { - turn, step, - // Correlation comes from the immutable execution input; the result does - // not duplicate this authoritative transcript identity. - callId: call.id, - content: result.content, - isError: result.isError, - ...result.error ? { error: result.error } : {}, - // Persist tool-owned presentation data for replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - // Accept into the batch FIFO immediately; entries remain deferred until - // every recorded result settles and survive abort or disposal afterward. - for (const context of result.additionalContexts ?? []) acceptContext(context) - // The signal may flip while the tool is awaited. - /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) - /* v8 ignore stop */ - } + await executeToolCalls( + ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext, + ) return { hadToolCalls: true, finish: assembler.finish } }) } -/** Preserve successful-call accounting without retaining output that result processing rejected. */ -async function processStepResult( - events: AgentEventDispatch, - session: Session, - turn: number, - step: number, - config: LlmCallConfig, - assembledContent: ContentBlock[], - message: Message, - assembler: BlockAssembler, - chunkSeqs: number[], -): Promise { - try { - return await events.waterfall( - 'agent/step-result', turn, step, message, () => Promise.resolve(message), - ) - } catch (error: unknown) { - recordAssistantMessage( - session, - turn, - step, - config, - assembledContent, - { ...message, content: [] }, - assembler, - chunkSeqs, - false, - ) - throw error - } -} - -/** Record one content-or-usage assistant message with replay-safe provenance. */ -function recordAssistantMessage( - session: Session, - turn: number, - step: number, - config: LlmCallConfig, - assembledContent: ContentBlock[], - message: Message, - assembler: BlockAssembler, - chunkSeqs: number[], - preserveReplayState = true, -): void { - session.append( - 'assistant/message', - { - turn, - step, - content: message.content, - provenance: assistantProvenance( - config, - assembler.replayState, - preserveReplayState && isDeepStrictEqual(message.content, assembledContent), - ), - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) -} - /** Build durable assistant provenance, dropping replay state after any content rewrite. */ function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable { return { diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts new file mode 100644 index 0000000000..663dda1b53 --- /dev/null +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -0,0 +1,253 @@ +/** + * Schedules one assistant step's tool calls. Exclusive calls form barriers; + * parallel calls use a bounded rolling pool and are reclassified before start. + * Dispatch may overlap, while policy, results, and result context remain + * model-ordered. Abort stops replenishment and drains started calls. + * + * Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls + * skipped after abort receive synthetic error results so replay stays valid. + * @module dsh-agent-loop/tool-calls + */ + +import type { Context } from 'cordis' +import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' +import type { HookContext } from '@deepseek-ai/dsh-agent' +import type { Session } from '@deepseek-ai/dsh-session' +import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' + +/** One tool call after argument parsing, ready to schedule. */ +interface PlannedCall { + block: ToolCallBlock + exec: ToolExecutionInput +} + +/** Settled dispatch awaiting model-order finalization. */ +interface Slot { + exec: ToolRunContext + result: ToolExecutionResult + needsPost: boolean +} + +/** One scheduler group outcome, including a drained cancellation. */ +interface GroupOutcome { + consumed: number + aborted: boolean +} + +/** + * Schedule one assistant step's tool calls by their live concurrency mode. + * Started calls receive ordered results. Abort drains them, records synthetic + * results for unstarted calls, and returns with the signal still aborted after + * accepting started-call context into the batch FIFO owned by the caller. + * The committed step's AgentLoop driver boundary supplies the initiating Agent + * that becomes each explicit {@link ToolExecutionInput.agent}. + * + * @param ctx - loop context that owns the tool registry and carries the initiating Agent. + * @param turn - current turn number. + * @param step - current step number. + * @param toolCalls - assistant calls in model order. + * @param signal - abort signal shared by the step. + * @param maxParallel - validated in-flight cap. + * @param acceptContext - accepts committed result context into the active batch. + */ +export async function executeToolCalls( + ctx: Context, + turn: number, + step: number, + toolCalls: ToolCallBlock[], + signal: AbortSignal, + maxParallel: number, + acceptContext: (context: HookContext) => void, +): Promise { + const agent = ctx.agents.requireInitiator() + const { session } = agent + + // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. + const planned: PlannedCall[] = toolCalls.map(block => ({ + block, + exec: { + callId: block.id, + name: block.name, + arguments: parseArguments(block.arguments), + agent, + signal, + }, + })) + + let next = 0 + while (next < planned.length) { + // Commit before classifying again so registry changes affect unstarted calls. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + const first = planned[next]! + const mode = ctx.tools.executionMode(first.exec).kind + const group = mode === 'parallel' ? planned.slice(next) : [first] + const outcome = await runGroup( + ctx, turn, step, group, mode, signal, maxParallel, acceptContext, + ) + next += outcome.consumed + if (outcome.aborted) { + for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) + return + } + } +} + +/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ +function parseArguments(raw: string): unknown { + try { + return raw ? JSON.parse(raw) : {} + } catch { + return raw + } +} + +/** + * Run one exclusive barrier or parallel pool. Later calls are reclassified + * before start; an exclusive reclassification waits for the current pool to + * drain and remains for the caller's next barrier. Results and contexts commit + * in model order. Abort stops starts, drains and commits started calls, accepts + * their contexts into the owning batch, records results for skipped calls, and + * returns an aborted outcome. + */ +async function runGroup( + ctx: Context, + turn: number, + step: number, + group: PlannedCall[], + mode: ToolExecutionMode['kind'], + signal: AbortSignal, + maxParallel: number, + acceptContext: (context: HookContext) => void, +): Promise { + const { session } = ctx.agents.requireInitiator() + const slots: (Slot | undefined)[] = group.map(() => undefined) + // Started slots retain their tool/call seq for result provenance. + const callSeqs: number[] = group.map(() => -1) + let nextToStart = 0 + let committed = 0 + let started = 0 + let aborted: boolean = signal.aborted + + // `committed` advances only across contiguous model-order slots. + const commitReady = async (): Promise => { + while (committed < group.length) { + const slot = slots[committed] + if (slot === undefined) break + const call = group[committed] + const result = slot.needsPost + ? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result) + : ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index + appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) + for (const context of result.additionalContexts ?? []) acceptContext(context) + committed++ + } + } + + const inFlight = new Map>() + + const startCall = async (index: number): Promise => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index + const call = group[index]! + callSeqs[index] = appendToolCall(session, turn, step, call.block) + started++ + const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec) + switch (prepared.kind) { + case 'dispatch': { + const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => { + slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } + return index + }) + inFlight.set(index, promise) + break + } + case 'post-result': + slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true } + break + case 'final-result': + slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false } + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(prepared, 'tool-call scheduler prepare result') + } + } + + const fillPool = async (): Promise => { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) { + // Re-read later modes after ordered commits so registry changes can create a barrier. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + const nextCall = group[nextToStart]! + if (nextToStart > 0 && mode === 'parallel' + && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break + await startCall(nextToStart) + nextToStart++ + await commitReady() + // Abort may arrive while pre-execute awaits. + if (signal.aborted) aborted = true + } + } + + // Ordered pre-execute may await; only dispatch/body overlaps. + // TODO: Drain every started call before rethrowing a scheduler error; tool + // bodies must not outlive the failed turn. + await fillPool() + while (inFlight.size > 0) { + const settledIndex = await Promise.race(inFlight.values()) + inFlight.delete(settledIndex) + await commitReady() + // Abort may arrive while a tool or ordered commit awaits. + + if (signal.aborted) aborted = true + await fillPool() + } + + if (aborted) { + // Started calls and accepted context settle first; every remaining model + // call then receives an ordered synthetic result before the turn aborts. + for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) + return { consumed: group.length, aborted: true } + } + /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ + if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') + return { consumed: started, aborted: false } +} + +/** Append the durable call/result pair for a model call skipped after cancellation. */ +function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { + const callSeq = appendToolCall(session, turn, step, block) + appendToolResult(session, turn, step, block, { + content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }], + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }, callSeq) +} + +/** Append a started call and return its provenance sequence. */ +function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number { + const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }) + return event.seq +} + +/** Append a model-ordered result linked to its call event. */ +function appendToolResult( + session: Session, + turn: number, + step: number, + block: ToolCallBlock, + result: ToolExecutionResult, + callSeq: number, +): void { + session.append('tool/result', { + turn, step, + // Correlation stays with the loop's authoritative model-transcript call id; + // registry results deliberately do not duplicate it. + callId: block.id, + content: result.content, + isError: result.isError, + ...result.error ? { error: result.error } : {}, + // The tool's private presentation payload (e.g. a result-time diff), + // persisted so a UI bridge reproduces the card on replay. + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) +} diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts new file mode 100644 index 0000000000..8e9b951fa1 --- /dev/null +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest' +import { Context, type Fiber } from 'cordis' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +interface Harness { + ctx: Context + agentsFiber: Fiber + loopFiber: Fiber +} + +async function harness(adapter: LlmAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + const agentsFiber = await ctx.plugin(AgentRegistry) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return { ctx, agentsFiber, loopFiber } +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function send(agent: Agent, text: string): void { + agent.send([{ type: 'text', text }]) +} + +/** Adapter that holds both drivers at the same awaited continuation. */ +class OverlapAdapter extends LlmAdapter { + private readonly bothStarted = Promise.withResolvers() + private starts = 0 + readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = [] + + constructor(private readonly ctx: Context) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + const before = this.ctx.agents.requireInitiator() + this.starts += 1 + if (this.starts === 2) this.bothStarted.resolve(true) + await this.bothStarted.promise + await Promise.resolve() + const after = this.ctx.agents.requireInitiator() + this.observations.push({ sessionId: options.sessionId, before, after }) + yield* textResponse('done') + } +} + +/** Test-only transport that materializes ambient identity at its request boundary. */ +class TestCapabilityTransport { + readonly requests: { path: string; headers: Record }[] = [] + + constructor(private readonly agents: AgentRegistry) {} + + async request(path: string): Promise> { + await Promise.resolve() + const headers = { + 'X-Harness-Session-Id': this.agents.requireInitiator().session.id, + } + this.requests.push({ path, headers }) + return headers + } +} + +/** Adapter whose first call waits for cancellation and whose later calls complete. */ +class ReloadAdapter extends LlmAdapter { + readonly firstStarted = Promise.withResolvers() + firstAgentDuringAbort: Agent | undefined + laterAgent: Agent | undefined + calls = 0 + agents: AgentRegistry | undefined + + async * stream(options: GenerateOptions): AsyncIterable { + const agents = this.agents + if (agents === undefined) throw new Error('agent service missing') + this.calls += 1 + if (this.calls === 1) { + this.firstStarted.resolve(true) + try { + await new Promise((_resolve, reject) => { + const abort = (): void => { reject(new Error('aborted')) } + if (options.signal?.aborted === true) abort() + else options.signal?.addEventListener('abort', abort, { once: true }) + }) + } catch (error: unknown) { + await Promise.resolve() + this.firstAgentDuringAbort = agents.requireInitiator() + throw error + } + return + } + await Promise.resolve() + this.laterAgent = agents.requireInitiator() + yield* textResponse('reloaded') + } +} + +describe('AgentLoop initiator scope', () => { + it('keeps overlapping driver continuations bound to their exact Agents', async () => { + const ctx = new Context() + const adapter = new OverlapAdapter(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + + const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) + const idleA = waitForIdle(ctx, a) + const idleB = waitForIdle(ctx, b) + send(a, 'a') + send(b, 'b') + await Promise.all([idleA, idleB]) + + expect(adapter.observations).toHaveLength(2) + expect(adapter.observations).toEqual(expect.arrayContaining([ + { sessionId: a.session.id, before: a, after: a }, + { sessionId: b.session.id, before: b, after: b }, + ])) + expect(ctx.agents.currentInitiator()).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => { + const adapter = new MockAdapter([ + toolCallResponse('spawn', 'spawn-child', {}), + toolCallResponse('observe', 'observe-child', {}), + textResponse('child done'), + textResponse('parent done'), + ]) + const { ctx } = await harness(adapter) + let parentDuringSetup: Agent | undefined + let explicitChild: Agent | undefined + let childDuringDriver: Agent | undefined + let parentWhileChildDriverActive: Agent | undefined + let child: Agent | undefined + + ctx.tools.register(defineTool({ + name: 'spawn-child', + description: 'create one child agent', + parameters: {}, + execute: async (_args, exec) => { + if (exec.agent === undefined) throw new Error('parent agent missing') + const handle = await exec.agent.ctx.agents.create({ + sessionId: SessionId('child-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + setup: (agentCtx) => { + parentDuringSetup = ctx.agents.requireInitiator() + explicitChild = agentCtx.agent + agentCtx.tools.register(defineTool({ + name: 'observe-child', + description: 'observe child execution identity', + parameters: {}, + execute: async () => { + await Promise.resolve() + childDuringDriver = ctx.agents.requireInitiator() + return [{ type: 'text', text: 'observed' }] + }, + })) + }, + }) + child = handle.agent + parentWhileChildDriverActive = ctx.agents.requireInitiator() + send(handle.agent, 'run child') + await handle.agent.whenIdle() + await handle.dispose() + return [{ type: 'text', text: 'child completed' }] + }, + })) + + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('parent-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const idle = waitForIdle(ctx, parentHandle.agent) + send(parentHandle.agent, 'spawn') + await idle + + expect(parentDuringSetup).toBe(parentHandle.agent) + expect(explicitChild).toBe(child) + expect(childDuringDriver).toBe(child) + expect(parentWhileChildDriverActive).toBe(parentHandle.agent) + expect(ctx.agents.currentInitiator()).toBeUndefined() + await parentHandle.dispose() + await ctx.fiber.dispose() + }) + + it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => { + const adapter = new MockAdapter([ + toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }), + textResponse('done'), + ]) + const { ctx } = await harness(adapter) + const transport = new TestCapabilityTransport(ctx.agents) + let directAmbient: Agent | undefined + let captured: Agent | undefined + + ctx.tools.register(defineTool({ + name: 'agentless-probe', + description: 'observe an agentless call', + parameters: {}, + execute: async () => { + await Promise.resolve() + directAmbient = ctx.agents.currentInitiator() + return [{ type: 'text', text: 'ok' }] + }, + })) + ctx.tools.register(defineTool({ + name: 'capability-request', + description: 'call the test capability transport', + parameters: { path: { type: 'string' } }, + execute: async (args) => { + captured = ctx.agents.requireInitiator() + const path = (args as { path: string }).path + const headers = await transport.request(path) + return [{ type: 'text', text: JSON.stringify(headers) }] + }, + })) + + const direct = await ctx.tools.execute({ + callId: CallId('direct'), + name: 'agentless-probe', + arguments: {}, + }) + expect(direct.isError).toBe(false) + expect(directAmbient).toBeUndefined() + + const handle = await ctx.agents.create({ + sessionId: SessionId('transport-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const idle = waitForIdle(ctx, handle.agent) + send(handle.agent, 'call transport') + await idle + + expect(transport.requests).toEqual([{ + path: '/v1/capability', + headers: { 'X-Harness-Session-Id': 'transport-session' }, + }]) + const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request') + expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i) + const call = handle.agent.session.events.find(event => event.type === 'tool/call') + expect(call?.type === 'tool/call' ? call.data.arguments : undefined) + .toBe(JSON.stringify({ path: '/v1/capability' })) + expect(captured).toBe(handle.agent) + + await handle.dispose() + expect(captured?.status).toBe('disposed') + expect(ctx.agents.currentInitiator()).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('drains the old driver before disabling ALS during agent-service restart', async () => { + const adapter = new ReloadAdapter() + const { ctx, agentsFiber, loopFiber } = await harness(adapter) + const oldService = ctx.agents + adapter.agents = oldService + const oldHandle = await ctx.agents.create({ + sessionId: SessionId('before-restart-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const oldAgent = oldHandle.agent + send(oldAgent, 'block') + await adapter.firstStarted.promise + + await agentsFiber.restart() + await loopFiber.await() + expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id) + expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session) + expect(oldAgent.status).toBe('disposed') + expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed') + expect(ctx.agents).not.toBe(oldService) + adapter.agents = ctx.agents + + const newHandle = await ctx.agents.create({ + sessionId: SessionId('after-restart-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const newAgent = newHandle.agent + const idle = waitForIdle(ctx, newAgent) + send(newAgent, 'continue') + await idle + expect(adapter.laterAgent?.id).toBe(newAgent.id) + expect(adapter.laterAgent?.session).toBe(newAgent.session) + await ctx.fiber.dispose() + }) + + it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => { + const ctx = new Context() + const adapter = new ReloadAdapter() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + const service = ctx.agents + adapter.agents = service + const handle = await ctx.agents.create({ + sessionId: SessionId('root-dispose-session'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const agent = handle.agent + send(agent, 'block') + await adapter.firstStarted.promise + + await ctx.fiber.dispose() + expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id) + expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session) + expect(agent.status).toBe('disposed') + expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') + }) +}) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 70c596cd10..d23b69efb7 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,15 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' +import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -22,7 +25,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -33,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise { +function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === expected) { @@ -44,19 +47,23 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('ReactLoopAgent', () => { +describe('Agent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) - const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { provider: 'mock', model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) expect(() => prepared.agent.ctx).toThrow('context is not bound') - expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { provider: 'mock', model: 'mock' }, session)) + expect(() => prepareReactLoopAgent( + ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + )) .toThrow('already has a concrete agent driver') await prepared.dispose() @@ -66,12 +73,12 @@ describe('ReactLoopAgent', () => { it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { provider: 'mock', model: 'mock' } - const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) + const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options) expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') - expect(agent.session.id).toMatch(/^owned-bindings-session-/) - expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) + expect(agent.session.id).toBe(agent.id) + expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() }) @@ -79,14 +86,14 @@ describe('ReactLoopAgent', () => { it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -94,14 +101,14 @@ describe('ReactLoopAgent', () => { it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -109,14 +116,14 @@ describe('ReactLoopAgent', () => { it('inject() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -124,7 +131,7 @@ describe('ReactLoopAgent', () => { it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Simulate an OPEN turn in the log while the agent is idle (status is not a // reliable open-turn signal). inject must append into that open turn, NOT @@ -150,7 +157,7 @@ describe('ReactLoopAgent', () => { // A persistence-like listener whose flush rejects. ctx.on('session/flush', () => { throw new Error('disk gone') }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // inject() is synchronous and fires a fire-and-forget flush; a rejecting // flush must be contained (logged), never thrown into the caller. @@ -163,12 +170,14 @@ describe('ReactLoopAgent', () => { it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Invalid injected content throws after turn/start. `finally` must still append turn/end and - // flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint. + // Non-serializable injected content makes Session.append throw AFTER + // turn/start was recorded. The turn/end must still be appended (finally), + // AND the durability checkpoint must still fire — the balanced turn is in + // memory and a crash before the next turn/dispose would otherwise lose it. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) }).toThrow(/non-JSON-serializable/) @@ -181,7 +190,7 @@ describe('ReactLoopAgent', () => { it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // Session contains a throwing post-commit turn/end observer. The accepted @@ -204,7 +213,7 @@ describe('ReactLoopAgent', () => { // A non-Error rejection exercises the String() normalization branch. ctx.on('session/flush', () => { throw 'disk gone' }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: { turn: number; step: number; message: string }[] = [] ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) @@ -223,7 +232,7 @@ describe('ReactLoopAgent', () => { it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A non-serializable source makes the turn/start append throw BEFORE the // event is pushed (Session.append validates before push), so NO turn opens. @@ -238,7 +247,7 @@ describe('ReactLoopAgent', () => { it('steer() when idle falls through to send() and starts a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -250,20 +259,29 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // The internal start seam exposes one idle driver's disposer for repeated invocation. + // Create a bare Agent and start it through the package-internal + // test seam. Then call its disposer twice — the second call hits the + // early-return branch. const ctx = new Context() await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(SessionId('test')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const { agent } = prepared + // Start the loop to get the disposer; the agent waits for messages + // (idle, never-resolving cancel), so it will stay idle. prepared.markPublished() const dispose = prepared.startDriver() + // First dispose const firstDisposal = dispose() expect(agent.status).toBe('disposed') await firstDisposal + // Second dispose — idempotent, no throw await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -272,7 +290,9 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) - const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) await prepared.dispose() expect(prepared.agent.status).toBe('disposed') @@ -286,7 +306,7 @@ describe('ReactLoopAgent', () => { it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -305,7 +325,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() resolves immediately when the agent is not running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -316,7 +336,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() waits for queued work that has not flipped status yet', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'queued') let settled = false @@ -334,8 +354,8 @@ describe('ReactLoopAgent', () => { it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -358,8 +378,10 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { - // Queue the internal waiter while running, then dispose the bare driver. Its disposed branch - // must chain the loop's `done` promise rather than resolve before exit. + // Covers the waiter's disposed arm: whenIdle() queues an internal waiter + // while running (not the fast path), then the disposer settles it and chains + // `done` (loop exit), not an eager resolve. A bare Agent + direct + // internal driver disposer keeps the emit synchronous. const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -369,7 +391,9 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session) + const prepared = prepareReactLoopAgent( + ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() @@ -385,13 +409,15 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { - // The waiter is agent-owned state, not an effect-scoped listener that owner disposal would - // remove before the disposed transition. Fiber teardown must still settle it. + // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: + // disposing the OWNING fiber runs the agent's listener disposers, which would + // have dropped a ctx.on-based waiter before the 'disposed' transition and + // hung the promise. With internal waiters, the fiber disposer still settles it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -404,19 +430,21 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => { - // Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it - // resolves only after true loop exit. + // The disposer emits agent/status('disposed') BEFORE the driver loop + // unwinds, so whenIdle() must chain `done` (true quiescence) on the + // disposed path. Dispose a running agent, then assert whenIdle() resolves + // only after `done` — i.e. the loop has actually exited. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) let doneResolved = false - void agent.done.then(() => { doneResolved = true }) + void driverDone(agent).then(() => { doneResolved = true }) await fiber.dispose() // sets status disposed, aborts, drains the loop expect(agent.status).toBe('disposed') @@ -431,7 +459,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -449,7 +477,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index fc6055a00c..14e7cf8976 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,10 +12,14 @@ import { Context } from 'cordis' import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { MockAdapter, textResponse } from './mock-adapter.ts' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} async function harness(adapter: MockAdapter) { const ctx = new Context() @@ -29,12 +33,12 @@ async function harness(adapter: MockAdapter) { return ctx } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -43,7 +47,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } /** All user-message texts recorded in the log (to assert what actually ran). */ -function userTexts(agent: ReactLoopAgent): string[] { +function userTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -54,7 +58,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. @@ -71,7 +75,7 @@ describe('Agent.cancel()', () => { it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. @@ -90,7 +94,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // This waiter cannot rely on a running→idle transition because cancellation // drops the turn before it runs; the skip path must settle it directly. @@ -109,7 +113,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -126,7 +130,7 @@ describe('Agent.cancel()', () => { it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -139,10 +143,63 @@ describe('Agent.cancel()', () => { expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }]) }) + it('cancel from an assistant/message observer skips execution but balances replay', async () => { + const adapter = new MockAdapter([ + toolCallResponse('c1', 'danger', {}), + textResponse('recovered after cancellation'), + ]) + const ctx = await harness(adapter) + let executions = 0 + ctx.tools.register(defineTool({ + name: 'danger', + description: 'must not run after cancellation', + parameters: {}, + async execute() { + executions += 1 + return [{ type: 'text', text: 'ran' }] + }, + })) + const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' }) + const dispose = ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/message') { + agent.cancel('cancelled after assistant message') + } + }) + + const reasons: TurnEndReason[] = [] + ctx.on('session/event', (_session, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + dispose() + + expect(executions).toBe(0) + expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled after assistant message' }]) + const call = agent.session.events.find(event => event.type === 'tool/call') + const result = agent.session.events.find(event => event.type === 'tool/result') + expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1') + expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({ + callId: 'c1', + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) + + send(agent, 'continue safely') + await waitForIdle(ctx, agent) + const replayedResult = adapter.requests[1]!.messages + .flatMap(message => message.content) + .find(block => block.type === 'tool-result') + expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true }) + expect(reasons).toEqual([ + { kind: 'aborted', reason: 'cancelled after assistant message' }, + { kind: 'completed' }, + ]) + }) + it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -164,7 +221,7 @@ describe('Agent.cancel()', () => { it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Prefix composition runs before the pre-step seam on the instance's first // step; a cancel landing inside it must drop the about-to-start step @@ -198,11 +255,10 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-prefix'), sessionId: SessionId('dispose-prefix-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -215,7 +271,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(resolve => setTimeout(resolve, 0)) await disposalDone - await agent.done + await driverDone(agent) // No step opened, no model call ran, and the turn closed disposed. expect(streamed).toBe(false) @@ -227,7 +283,7 @@ describe('Agent.cancel()', () => { it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // The interrupted first composition must not cache its degraded empty value; // the next prompt recomposes and logs/sends the fresh prefix. @@ -257,7 +313,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A turn/start listener fires before a step controller exists, so the // turn-scoped marker—not step abort—must drop the pending step. @@ -284,7 +340,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A step/start session-event listener fires AFTER step/start is appended // (and after the pre-step seam), so cancelling there lands in the SECOND @@ -323,11 +379,10 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-step-start'), sessionId: SessionId('dispose-step-start-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -338,7 +393,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await disposalDone - await agent.done + await driverDone(agent) expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) @@ -355,7 +410,7 @@ describe('Agent.cancel()', () => { // `aborted` and run NO second step. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 const reasons: TurnEndReason[] = [] @@ -387,7 +442,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // `agent/status` is synchronous, so cancellation can land after the first // pre-step check; the second check must drop the now-empty turn. @@ -411,7 +466,7 @@ describe('Agent.cancel()', () => { // Cancellation must not settle idle while replacement work remains queued. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -438,7 +493,7 @@ describe('Agent.cancel()', () => { // prompt B is queued before the loop resumes from the idle wait. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'A') // queues A (status still idle, loop microtask pending) const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) @@ -457,7 +512,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 86db33a8ca..cadc176aea 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,15 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -23,7 +24,281 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +async function makeCoreContext(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + describe('config-driven session id', () => { + it('rejects an empty exact id before publishing an agent', async () => { + const ctx = await makeCoreContext() + await expect(ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId(''), model: 'mock' }], + })).rejects.toThrow('expected string length >= 1') + expect(ctx.agents.get(SessionId(''))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('accepts one exact fresh id and rejects it alongside a resume id', async () => { + const exact = await makeCoreContext() + await exact.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + }) + expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + await exact.fiber.dispose() + + const conflicting = await makeCoreContext() + await expect(conflicting.plugin(AgentLoop, { + agents: [{ + id: 'main', + sessionId: SessionId('fresh'), + resumeSessionId: SessionId('persisted'), + model: 'mock', + }], + })).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive') + await conflicting.fiber.dispose() + }) + + it('rejects duplicate exact ids before asynchronous configured startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-duplicate-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + + const outcome = await ctx.plugin(AgentLoop, { + agents: [ + { id: 'first', sessionId: SessionId('shared'), model: 'mock' }, + { id: 'second', sessionId: SessionId('shared'), model: 'mock' }, + ], + }).then(() => undefined, (error: unknown) => error) + const published = ctx.agents.get(SessionId('shared')) + await ctx.fiber.dispose() + + expect(outcome).toEqual(new Error('agents "first" and "second" use duplicate exact session identity "shared"')) + expect(published).toBeUndefined() + }) + + it('restores a materialized exact id across an AgentLoop-only reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) + const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] } + + const firstLoop = await ctx.plugin(AgentLoop, config) + let first: Agent | undefined + for (let i = 0; i < 50 && first === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + first = ctx.agents.get(SessionId('stdio-exact-reload')) + } + expect(first).toBeDefined() + first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, first!) + await firstLoop.dispose() + + const secondLoop = await ctx.plugin(AgentLoop, config) + let second: Agent | undefined + for (let i = 0; i < 50 && second === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + second = ctx.agents.get(SessionId('stdio-exact-reload')) + } + expect(second).toBeDefined() + expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') + second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, second!) + await ctx.sessions.flush(second!.session) + const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload')) + expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + + it('waits for a draining exact-id lifecycle during an overlapping reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-overlap') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as Agent + + const flushGate = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', (session) => { + if (session !== first.session) return + flushStarted = true + return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before replacement' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + expect(flushStarted).toBe(true) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + const secondLoop = await ctx.plugin(AgentLoop, config) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.agents.get(sessionId)).toBe(first) + expect(failures).toEqual([]) + + flushGate.resolve(undefined) + await firstDisposal + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const second = ctx.agents.get(sessionId) as Agent + expect(second).not.toBe(first) + expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement') + expect(failures).toEqual([]) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + + it('cancels an exact-id reload while the prior lifecycle is still draining', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-cancel-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-cancel') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as Agent + + const flushGate = Promise.withResolvers() + ctx.on('session/flush', (session) => { + if (session === first.session) return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before cancellation' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const secondLoop = await ctx.plugin(AgentLoop, config) + await secondLoop.dispose() + expect(ctx.agents.get(sessionId)).toBe(first) + + flushGate.resolve(undefined) + await firstDisposal + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('contains an exact-id persistence lookup failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const failure = new Error('persistence index failed') + const listenerFailure = new Error('failure observer failed') + const asyncListenerFailure = new Error('async failure observer failed') + const failures: { sessionId: SessionId; error: unknown }[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) + ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) + ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + failures.push({ sessionId, error }) + }) + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }], + }) + + await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( + 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', + )) + expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: Error: failure observer failed', + ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: Error: async failure observer failed', + ) + expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() + warn.mockRestore() + await ctx.fiber.dispose() + }) + + it('contains startup and observer failures whose string coercion throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-unrenderable-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const unrenderable = { + [Symbol.toPrimitive](): never { + throw new Error('coercion escaped') + }, + } + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw unrenderable }) + // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }], + }) + + await expect.poll(() => failures).toEqual([unrenderable]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + ) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: ', + ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: ', + ) + await ctx.fiber.dispose() + }) + + it.each(['resolve', 'reject'] as const)( + 'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes', + async (outcome) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const listing = Promise.withResolvers>>() + vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + + const loop = await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + }) + let disposed = false + const disposal = loop.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + if (outcome === 'resolve') listing.resolve([]) + else listing.reject(new Error('startup cancelled by teardown')) + await disposal + expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(failures).toEqual([]) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + await ctx.fiber.dispose() + }, + ) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -32,7 +307,7 @@ describe('config-driven session id', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], }) const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') @@ -53,11 +328,13 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as Agent + expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) + expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -70,10 +347,11 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as Agent + expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -96,7 +374,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -109,19 +387,20 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) // The deferred resume runs on a microtask after the backend is available. - let resumed: ReactLoopAgent | undefined + let resumed: Agent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. + expect(resumed!.id).toBe(SessionId('sticky-1')) expect(resumed!.session.id).toBe('sticky-1') const derived = resumed!.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') @@ -137,16 +416,16 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) // The deferred resume fails (no such session on disk). It must be contained: - // a warning is logged, no 'main' agent is registered, and the app stays up. + // a warning is logged, no agent is registered, and the app stays up. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() + expect(ctx.agents.list()).toEqual([]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index cb8abf316b..80d085c32b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -4,12 +4,16 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ async function harness(adapter: MockAdapter) { @@ -24,7 +28,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -35,7 +39,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -55,7 +59,7 @@ describe('session log records what agent/step-result actually produced', () => { return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -97,7 +101,7 @@ describe('session log records what agent/step-result actually produced', () => { response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState } const adapter = new MockAdapter([response]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('replay-state'), { provider: 'mock', model: 'next-model' }) + const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -121,7 +125,7 @@ describe('session log records what agent/step-result actually produced', () => { if (block?.type === 'text') block.text = 'mutated' return message }) - const agent = ctx.agentLoop.create(AgentId('mutated-replay-state'), { provider: 'mock', model: 'next-model' }) + const agent = ctx.agentLoop.create(SessionId('mutated-replay-state'), { provider: 'mock', model: 'next-model' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -141,7 +145,7 @@ describe('successful provider completion survives agent/step-result failure', () const adapter = new MockAdapter([response]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId(id), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' }) const failure = new Error(`${id} result processing failed`) const reported: Error[] = [] @@ -200,7 +204,7 @@ describe('successful provider completion survives agent/step-result failure', () }) describe('abort during tool execution ends the turn', () => { - it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { + it('balances an aborted tool batch through context, steering, and post-step before closing', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step [ @@ -214,21 +218,29 @@ describe('abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', parameters: {}, - async execute() { + async execute(_args, exec) { executed.push('aborter') - // Fire the in-flight step's AbortController directly (the loop registers - // it on the agent). This is the bare step-abort path — distinct from - // cancel(), which would also clear the inbox; here the subject is the - // loop's response to its running step being aborted mid-tool. + exec.agent?.steer( + [{ type: 'text', text: 'steering before abort' }], + { source: { kind: 'plugin', plugin: 'abort-test' } }, + ) + // Exercise bare step abort without `cancel()` clearing queued work. ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt') return [{ type: 'text', text: 'done' }] }, })) + ctx.on('tools/post-execute', async exec => ({ + kind: 'accept', + additionalContexts: [{ + content: [{ type: 'text', text: `context for ${exec.callId}` }], + source: { kind: 'plugin', plugin: 'abort-test' }, + }], + })) ctx.tools.register(defineTool({ name: 'second', description: '', @@ -240,20 +252,70 @@ describe('abort during tool execution ends the turn', () => { })) const reasons: TurnEndReason[] = [] - ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) + const order: string[] = [] + ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + switch (event.type) { + case 'assistant/message': order.push('assistant/message'); break + case 'tool/call': order.push(`tool/call:${event.data.callId}`); break + case 'tool/result': { + const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real' + order.push(`tool/result:${event.data.callId}:${outcome}`) + break + } + case 'context/message': order.push('context/message'); break + case 'steering/message': order.push('steering/message'); break + case 'step/end': order.push('step/end'); break + case 'turn/end': { + reasons.push(event.data.reason) + order.push(`turn/end:${event.data.reason.kind}`) + break + } + } + }) + let postSteps = 0 + ctx.on('agent/post-step', (subject, turn, step, signal) => { + if (subject !== agent) return + postSteps += 1 + expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true }) + order.push('agent/post-step') + }) send(agent, 'go') await waitForIdle(ctx, agent) - expect(executed).toEqual(['aborter']) // second tool never ran - expect(adapter.requests).toHaveLength(1) // no follow-up model call + expect(executed).toEqual(['aborter']) + expect(adapter.requests).toHaveLength(1) + expect(postSteps).toBe(1) + expect(order).toEqual([ + 'assistant/message', + 'tool/call:c1', + 'tool/result:c1:real', + 'tool/call:c2', + 'tool/result:c2:synthetic-aborted', + 'context/message', + 'steering/message', + 'agent/post-step', + 'step/end', + 'turn/end:aborted', + ]) expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }]) + const calls = agent.session.events.filter(event => event.type === 'tool/call') + const results = agent.session.events.filter(event => event.type === 'tool/result') + expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')]) + expect(results).toHaveLength(2) + expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false }) + expect(results[1]!.data).toMatchObject({ + callId: CallId('c2'), + isError: true, + error: { name: 'AbortError', code: 'ABORTED' }, + }) }) it('records context accepted before a tool-step abort in the same turn', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-abort-injection'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -299,7 +361,7 @@ describe('abort during tool execution ends the turn', () => { { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'first', description: '', @@ -345,9 +407,9 @@ describe('abort during tool execution ends the turn', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) const ctx = await harness(adapter) const started = Promise.withResolvers() - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.tools.register(defineTool({ name: 'waiter', @@ -400,7 +462,7 @@ describe('abort during tool execution ends the turn', () => { textResponse('later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -442,7 +504,7 @@ describe('steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -468,7 +530,7 @@ describe('steering from late extension points is never stranded', () => { textResponse('after goal reminder'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steeredOnce = false ctx.on('session/event', (subject, event) => { @@ -496,7 +558,7 @@ describe('steering from late extension points is never stranded', () => { it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const turns: number[] = [] let steeredOnce = false @@ -522,7 +584,7 @@ describe('steering from late extension points is never stranded', () => { it('steering queued during an aborted step is re-delivered, not silently consumed', async () => { const adapter = new MockAdapter(['hang', textResponse('recovered')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -545,7 +607,7 @@ describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -573,7 +635,7 @@ describe('plugin exceptions are contained', () => { it('a rejecting session/flush listener is reported but does not kill the agent', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -601,9 +663,9 @@ describe('disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -614,7 +676,7 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) @@ -624,9 +686,9 @@ describe('disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -636,10 +698,10 @@ describe('disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done // must not hang + await driverDone(agent) // must not hang expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -658,7 +720,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -673,7 +735,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('the agent/request waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, provider: 'mock', model: 'mock' } @@ -688,7 +750,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -716,7 +778,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('send() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-send'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-send'), { provider: 'mock', model: 'mock' }) const content = [{ type: 'text' as const, text: 'accepted-send' }] const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined @@ -752,7 +814,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => { it('running steer() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-steer'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() ctx.tools.register(defineTool({ @@ -806,7 +868,7 @@ describe('turn numbering continues across seeded sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -822,7 +884,9 @@ describe('turn numbering continues across seeded sessions', () => { ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded) + const prepared = prepareReactLoopAgent( + ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS, + ) const forked = prepared.agent prepared.markPublished() ctx2.effect(() => prepared.startDriver()) @@ -866,7 +930,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -891,7 +955,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -909,7 +973,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', () ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -925,7 +989,7 @@ describe('step boundary publication order', () => { it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-step-order'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' }) // Append commits before observers run. const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] @@ -965,7 +1029,7 @@ describe('turn and step boundary recovery', () => { } /** Count turn/step boundary events for balance assertions. */ - function boundaryCounts(agent: ReactLoopAgent) { + function boundaryCounts(agent: Agent) { const e = [...agent.session.events] return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -980,7 +1044,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/start observer cannot change a successful turn', async () => { const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' }) // Session owns post-commit containment. The loop sees a successful append, // runs the request, and balances the ordinary step and turn boundaries. @@ -1009,7 +1073,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1040,7 +1104,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1074,7 +1138,7 @@ describe('turn and step boundary recovery', () => { it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -1106,7 +1170,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -1137,9 +1201,9 @@ describe('turn and step boundary recovery', () => { // balanced with reason disposed (no error event for a disposal). const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1148,7 +1212,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during the hanging step - await agent.done + await driverDone(agent) const e = [...agent.session.events] const turnStarts = e.filter(x => x.type === 'turn/start').length @@ -1164,9 +1228,9 @@ describe('turn and step boundary recovery', () => { // Disposal remains authoritative when the listener also throws. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) let threw = false @@ -1180,7 +1244,7 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await agent.done + await driverDone(agent) const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). @@ -1197,7 +1261,7 @@ describe('turn and step boundary recovery', () => { it('a throwing turn/start observer cannot starve the loop or later turns', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-preturn'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -1228,7 +1292,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1267,7 +1331,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1297,7 +1361,7 @@ describe('turn and step boundary recovery', () => { // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1343,7 +1407,7 @@ describe('tool result call identity', () => { return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) - const agent = ctx.agentLoop.create(AgentId('a-callid'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -1374,7 +1438,7 @@ describe('surface: assistant/message records exact empty provenance when no chun const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ role: 'assistant' as const, @@ -1419,9 +1483,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1436,7 +1500,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) unlisten() // Turn boundaries are durable rows; there is no `agent/*` mirror to assert. @@ -1469,9 +1533,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1484,7 +1548,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1523,9 +1587,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1538,7 +1602,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releasePreStep() await disposalDone - await agent.done + await driverDone(agent) // After the pre-step seam finishes, the post-seam cancel/dispose check // catches disposal. The step was never opened, no LLM call was made. @@ -1574,9 +1638,9 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1589,7 +1653,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releasePreStep() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) @@ -1624,9 +1688,9 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') @@ -1635,7 +1699,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index ddbac2716d..b324c02b6c 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,14 +1,19 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -21,7 +26,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -32,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -40,7 +45,7 @@ describe('inbox acceptance', () => { it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let queued = 0 ctx.on('agent/queued', () => { queued += 1 }) @@ -80,7 +85,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -113,7 +118,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -126,7 +131,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('internal/dispatch', (_mode, name, args) => { @@ -152,7 +157,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -180,7 +185,7 @@ describe('coded error data emission', () => { it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -212,9 +217,9 @@ describe('disposed vs aborted branching', () => { it('handles dispose during model streaming producing reason "disposed"', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -223,7 +228,7 @@ describe('disposed vs aborted branching', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during hang - await agent.done + await driverDone(agent) // Disposal wins abort classification because the error path checks it first. expect(reasons).toContainEqual({ kind: 'disposed' }) @@ -240,7 +245,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 9932d9ca25..6c0757b460 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,16 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { - AgentId, - type ContinuationDecision, - type PromptDecision, - type SessionStartSource, -} from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -34,7 +30,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -45,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -57,7 +53,7 @@ describe('agent/prompt-submit', () => { it('allow (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { @@ -76,7 +72,7 @@ describe('agent/prompt-submit', () => { it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) @@ -94,7 +90,7 @@ describe('agent/prompt-submit', () => { it('allow with additionalContexts injects separate context/message events into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const meta = { kind: 'prompt-context', version: 1 } ctx.on('agent/prompt-submit', async (): Promise => @@ -119,17 +115,14 @@ describe('agent/prompt-submit', () => { expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' }) expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw') expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta) - // both the prompt and the injected context reach the model const sent = JSON.stringify(adapter.requests[0]!.messages) expect(sent).toContain('extra ctx') }) - it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { - // Prompt rewrites and injected context land before `agent/pre-step`, so a - // compaction listener measures the current surface before the single derive. + it('runs pre-step after prompt rewrites and injected context become durable', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -138,8 +131,6 @@ describe('agent/prompt-submit', () => { additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }], })) - // The pre-step seam (where compaction lives) derives the surface it would act - // on. Capture what it sees on the first step. let preStepDerived: string | undefined ctx.on('agent/pre-step', (subject, _turn, step) => { if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages()) @@ -148,8 +139,6 @@ describe('agent/prompt-submit', () => { send(agent, 'ORIGINAL prompt') await waitForIdle(ctx, agent) - // The pre-step seam ran and saw BOTH the rewrite (not the original) and the - // injected context — i.e. the prompt-submit effects landed before it. expect(preStepDerived).toBeDefined() expect(preStepDerived).toContain('REWRITTEN prompt') expect(preStepDerived).toContain('injected ctx') @@ -159,7 +148,7 @@ describe('agent/prompt-submit', () => { it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'block', reason: 'blocked by policy' })) @@ -195,7 +184,7 @@ describe('agent/prompt-submit', () => { // the allowed prompt keeps the turn from ending rejected. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') @@ -231,7 +220,7 @@ describe('agent/prompt-submit', () => { it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false ctx.on('agent/prompt-submit', async () => { @@ -264,7 +253,7 @@ describe('agent/session-start', () => { const sources: SessionStartSource[] = [] ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) @@ -283,7 +272,7 @@ describe('agent/session-start', () => { agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -301,8 +290,8 @@ describe('agent/session-start', () => { ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - expect(agent.id).toBe(AgentId('a1')) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + expect(agent.id).toBe(SessionId('a1')) // and the agent still runs send(agent, 'go') @@ -315,8 +304,8 @@ describe('agent/session-prefix', () => { it('dispatches to global and matching agent-scope listeners only', async () => { const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) const ctx = await harness(adapter) - const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { provider: 'mock', model: 'mock' }) - const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { provider: 'mock', model: 'mock' }) + const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' }) + const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' }) const seen: string[] = [] ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { seen.push(`global:${agent.id}`) @@ -353,7 +342,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } let composed = 0 @@ -383,10 +372,10 @@ describe('agent/session-prefix', () => { expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] }) }) - it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => { + it('composes before the first pre-step and records the prefix on the request header', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] } const order: string[] = [] @@ -394,26 +383,21 @@ describe('agent/session-prefix', () => { order.push('compose') return [reminder, ...await next()] }) - const seen: (readonly Message[])[] = [] - ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => { + ctx.on('agent/pre-step', () => { order.push('pre-step') - seen.push(sessionPrefix) }) send(agent, 'hi') await waitForIdle(ctx, agent) - // Composition precedes the pre-step seam, and the seam receives THIS - // instance's composed prefix — a token-pressure gate (compaction) counts - // what the request will actually carry, never a stale logged prefix. expect(order).toEqual(['compose', 'pre-step']) - expect(seen[0]).toEqual([reminder]) + expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder]) }) it('the canonical prepend pattern composes contributions in registration order', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Both listeners use the canonical `[mine, ...await next()]` prepend: the // waterfall unwinds innermost-first (the second listener's array is built @@ -435,7 +419,7 @@ describe('agent/session-prefix', () => { it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // A listener that delegates without contributing — the canonical no-op. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) @@ -451,7 +435,7 @@ describe('agent/session-prefix', () => { it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let mutationError: unknown ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { @@ -480,7 +464,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) @@ -501,7 +485,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a continue decision with a reason records next-step steering in the same turn', async () => { const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let forced = false ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise => { @@ -533,7 +517,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/turn-continuation', async (): Promise => ({ action: 'stop' })) @@ -563,7 +547,7 @@ describe('tool additionalContexts buffering across a step', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Each call attaches one context naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -611,7 +595,7 @@ describe('tool additionalContexts buffering across a step', () => { return [{ type: 'text', text: 'outer result' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -638,7 +622,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } @@ -700,7 +684,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) @@ -723,7 +707,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - const agent = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -742,7 +726,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). - const agent = ctx.agentLoop.create(AgentId('a3'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the prompt-submit listener was disposed diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 6f4f15ba0c..c58479609b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,10 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) @@ -25,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') { * invoke this right after send(), when the loop hasn't woken yet (status is * still 'idle' synchronously), so polling the current status would lie. */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -36,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -44,7 +49,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // All boundaries — turn and step — are durable session events on the // session/event feed (no agent/* mirror). Record them in fire order to @@ -92,7 +97,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -131,7 +136,7 @@ describe('agent loop', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -155,7 +160,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -169,13 +174,12 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'Working in {{cwd}}.') const handle = await ctx.agents.create({ - agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent send(agent, 'hi') await waitForIdle(ctx, agent) @@ -188,7 +192,7 @@ describe('agent loop', () => { const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -230,7 +234,7 @@ describe('agent loop', () => { ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, provider: 'mock', model: 'mock' } }) - const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) + const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -256,7 +260,7 @@ describe('agent loop', () => { parameters: {}, execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), })) - const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -285,7 +289,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) - const agent = ctx.agentLoop.create(AgentId('a-no-system'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -297,7 +301,7 @@ describe('agent loop', () => { it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -321,7 +325,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -353,7 +357,7 @@ describe('agent loop', () => { it('steering while idle behaves like send (starts a turn)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -363,7 +367,7 @@ describe('agent loop', () => { it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) // The idle inject records a self-contained turn (turn/start → context/message @@ -387,7 +391,7 @@ describe('agent loop', () => { it('inject() can persist raw structured context without the generic context envelope', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('raw-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' }) const text = 'Additional instructions from: pkg/AGENTS.md' const meta = { kind: 'workspace-instructions', @@ -416,7 +420,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let visibleDuringTool = false const meta = { kind: 'deferred-test', version: 1 } ctx.tools.register(defineTool({ @@ -482,7 +486,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('invalid-context'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' }) ctx.tools.register(defineTool({ name: 'invalid-injector', description: 'attempts an invalid context injection', @@ -512,7 +516,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -538,7 +542,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const) @@ -553,7 +557,7 @@ describe('agent loop', () => { it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { // The seed is frozen — config is not a mutable per-call knob; a switch @@ -573,10 +577,6 @@ describe('agent loop', () => { }) it('agent/pre-step fires once per step before the step is opened', async () => { - // Two steps (a tool call, then a final text turn) → two model calls → two - // pre-step fires, each carrying the assembled full system prompt, BEFORE - // the step is opened and its request is derived (the request the adapter - // sees reflects any surface state at fire time). const adapter = new MockAdapter([ toolCallResponse('c1', 'echo', {}, 'calling echo'), textResponse('done'), @@ -586,23 +586,21 @@ describe('agent loop', () => { name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] - ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { - if (subject === agent) fires.push({ turn, step, fullSystemPrompt }) + const fires: { turn: number; step: number; signal: AbortSignal }[] = [] + ctx.on('agent/pre-step', (subject, turn, step, signal) => { + if (subject === agent) fires.push({ turn, step, signal }) }) send(agent, 'go') await waitForIdle(ctx, agent) - // One fire per step, in order, each with the assembled system prompt - // (here just the loop's own harness-identity section — no persona set). - const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.' - expect(fires).toEqual([ - { turn: 1, step: 1, fullSystemPrompt: HARNESS }, - { turn: 1, step: 2, fullSystemPrompt: HARNESS }, + expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([ + { turn: 1, step: 1 }, + { turn: 1, step: 2 }, ]) + expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true) }) it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => { @@ -610,7 +608,7 @@ describe('agent loop', () => { // same step's request must include it. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false ctx.on('agent/pre-step', (subject) => { @@ -644,7 +642,7 @@ describe('agent loop', () => { // closing, the turn records error, and the loop remains available. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let throwOnce = true ctx.on('agent/pre-step', () => { @@ -678,7 +676,7 @@ describe('agent loop', () => { it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -698,7 +696,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -721,7 +719,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -752,7 +750,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -785,7 +783,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -822,7 +820,7 @@ describe('agent loop', () => { parameters: { text: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -847,7 +845,7 @@ describe('agent loop', () => { // a durable successful-call boundary for replay consumers. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -884,7 +882,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -911,7 +909,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let threw = false // Post-commit session observers cannot control the loop. The tool call still // drives the second model request, and the turn completes normally. @@ -930,7 +928,7 @@ describe('agent loop', () => { it('chains queued messages into consecutive turns', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const turns: number[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -955,7 +953,7 @@ describe('agent loop', () => { it('awaits session/flush at turn end (persistence checkpoint)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -975,7 +973,7 @@ describe('agent loop', () => { it('errors from the model surface as agent/error and end the turn', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -998,21 +996,21 @@ describe('agent loop', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { provider: 'mock', model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) + expect(ctx.agents.get(SessionId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') await fiber.dispose() - await agent.done + await driverDone(agent) expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -1025,13 +1023,14 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() - expect(agent.id).toBe('config-agent') + expect(agent.id).toBe(agent.session.id) + expect(agent.id).toMatch(/^config-agent-session-/) expect(agent.options.model).toBe('mock') // the agent is alive: send triggers a turn @@ -1048,10 +1047,10 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], + agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) @@ -1069,7 +1068,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 6e502187a6..2becadcc40 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,7 +1,12 @@ /** - * Deterministic property tests for inbox scheduling: every sent message logs - * once, turn numbers increase, and status follows idle→running→idle/disposed. - * Schedules advance on status events rather than wall-clock sleeps. + * Property-based tests for the agent loop's inbox/turn scheduling (the + * property-testing RFC). Deterministic by construction: schedules are driven + * through the `agent/status` settle signal (no wall-clock sleeps), so a flake + * is a finding, not timing noise. + * + * Invariants: every sent message appears exactly once in the log (none lost); + * turn numbers strictly increase; status transitions follow the legal machine + * idle→running→idle (and →disposed at teardown). */ import { describe, expect, it } from 'vitest' @@ -9,11 +14,12 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -42,7 +48,7 @@ async function harness() { } /** Resolve on the agent's next transition to idle (event-based, not polled). */ -function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -55,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { /** Record every status transition for the legal-machine assertion. Returns * the seen list plus a disposer for the listener (per the registry convention). */ -function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } { +function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent) seen.push(status) @@ -63,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di return { seen, dispose } } -function userMessageTexts(agent: ReactLoopAgent): string[] { +function userMessageTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } -function turnNumbers(agent: ReactLoopAgent): number[] { +function turnNumbers(agent: Agent): number[] { return agent.session.events .filter(e => e.type === 'turn/start') .map(e => (e.data as { turn: number }).turn) @@ -90,7 +96,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. @@ -115,7 +121,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -140,7 +146,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) // Capture before each send; the last waiter covers the final turn, and // awaiting an already-settled earlier waiter is harmless. let lastIdle: Promise | undefined diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 72c2c577ab..9fc832fd0a 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -69,7 +70,7 @@ function waitForIdle(context: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { it('every request after the first hits the provider prefix cache', async () => { ctx = await loopHarness() - const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index ab2c18c99d..46cfe3eb56 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -12,8 +12,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -28,7 +29,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -39,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -71,7 +72,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -92,7 +93,7 @@ describe('request stability across the loop', () => { it('a later turn append-extends the previous turn (one conversation, one log)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -106,7 +107,7 @@ describe('request stability across the loop', () => { it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -139,7 +140,7 @@ describe('request stability across the loop', () => { it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -163,7 +164,7 @@ describe('request stability across the loop', () => { it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) let injected = false ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { @@ -191,7 +192,7 @@ describe('request stability across the loop', () => { it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -212,7 +213,7 @@ describe('request stability across the loop', () => { it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('gen1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -221,12 +222,11 @@ describe('request stability across the loop', () => { const adapter2 = new MockAdapter([textResponse('two')]) const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ - agentId: AgentId('gen2'), sessionId: SessionId('gen2-session'), seed: [...agent.session.events], agentOptions: { provider: 'mock', model: 'mock' }, }) - const agent2 = handle.agent as ReactLoopAgent + const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) @@ -241,7 +241,7 @@ describe('request stability across the loop', () => { it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { const config = await next() @@ -275,7 +275,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-recovery.spec.ts b/packages/core/agent-loop/tests/request-recovery.spec.ts new file mode 100644 index 0000000000..bfbcad23ba --- /dev/null +++ b/packages/core/agent-loop/tests/request-recovery.spec.ts @@ -0,0 +1,516 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import LlmService, { + CallId, + CONTEXT_WINDOW_EXCEEDED_CODE, + LlmAdapter, + LlmError, +} from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' + +class FailureScriptAdapter extends LlmAdapter { + requests: GenerateOptions[] = [] + + constructor(private readonly entries: (Error | StreamChunk[])[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.entries.shift() + if (entry === undefined) throw new Error('failure script exhausted') + if (entry instanceof Error) throw entry + yield* entry + } +} + +class IteratorConstructionFailureAdapter extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + throw new LlmError('iterator construction failed', 'ITERATOR_CONSTRUCTION') + }, + } + } +} + +class SynchronousDispatchFailureAdapter extends LlmAdapter { + constructor(private readonly error: Error) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + throw this.error + } +} + +class IteratorResultGetterFailureAdapter extends LlmAdapter { + constructor( + private readonly field: 'done' | 'value', + private readonly error: Error, + ) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + const result = this.field === 'done' ? {} : { done: false } + Object.defineProperty(result, this.field, { get: () => { throw this.error } }) + return { + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.resolve(result as unknown as IteratorResult) } + }, + } + } +} + +const streamListenerFailureCases: readonly [string, (ctx: Context) => void][] = [ + ['synchronous listener throw', (ctx) => { + ctx.on('llm/stream', () => { throw new Error('synchronous stream listener failed') }) + }], + ['invalid listener iterable', (ctx) => { + ctx.on('llm/stream', () => ({}) as AsyncIterable) + }], + ['listener wrapper iteration failure', (ctx) => { + ctx.on('llm/stream', (_options, next) => (async function * () { + for await (const chunk of next()) { + yield chunk + throw new Error('stream listener wrapper failed') + } + })()) + }], +] + +async function harness(adapter?: LlmAdapter): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + if (adapter) ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { + dispose() + resolve() + } + }) + }) +} + +function send(agent: Agent): void { + agent.send([{ type: 'text', text: 'go' }]) +} + +function contextError(message = 'context too large'): LlmError { + return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE) +} + +describe('agent post-step and request-error lifecycle', () => { + it('fires post-step after results, buffered context, and steering but before step/end', async () => { + const twoCalls: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'work', arguments: '{}' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-2'), name: 'work', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'work', + description: 'do work', + parameters: {}, + async execute(_args, exec) { + if (exec.callId === CallId('call-2')) { + exec.agent?.steer([{ type: 'text', text: 'steered' }], { source: { kind: 'plugin', plugin: 'test' } }) + } + return [{ type: 'text', text: 'worked' }] + }, + })) + ctx.on('tools/post-execute', async (exec, _result): Promise => ({ + kind: 'accept', + additionalContexts: [{ + content: [{ type: 'text', text: `context for ${exec.callId}` }], + source: { kind: 'plugin', plugin: 'test' }, + }], + })) + const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' }) + const order: string[] = [] + ctx.on('session/event', (_session, event) => { + if ( + event.type === 'assistant/message' || event.type === 'tool/call' + || event.type === 'tool/result' || event.type === 'context/message' + || event.type === 'steering/message' || event.type === 'step/end' + ) { + if (!('step' in event.data) || event.data.step === 1) order.push(event.type) + } + }) + ctx.on('agent/post-step', (subject, turn, step, signal) => { + if (subject !== agent || step !== 1) return + expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: false }) + subject.inject([{ type: 'text', text: 'listener mutation' }], { source: { kind: 'plugin', plugin: 'post-step' } }) + order.push('agent/post-step') + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(order).toEqual([ + 'assistant/message', + 'tool/call', + 'tool/result', + 'tool/call', + 'tool/result', + 'context/message', + 'context/message', + 'steering/message', + 'context/message', + 'agent/post-step', + 'step/end', + ]) + }) + + it('fires post-step for max-tokens and lets cancellation override that success', async () => { + const adapter = new FailureScriptAdapter([maxTokensResponse('partial')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('cancel-post-step-max-tokens'), { provider: 'mock', model: 'mock' }) + let entered!: () => void + const postStepEntered = new Promise((resolve) => { entered = resolve }) + ctx.on('agent/post-step', async (_agent, turn, step, signal) => { + expect({ turn, step }).toEqual({ turn: 1, step: 1 }) + entered() + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }) + + send(agent) + const idle = waitForIdle(ctx, agent) + await postStepEntered + agent.cancel('cancelled during max-tokens post-step') + await idle + + expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ + data: { usage: { inputTokens: 10, outputTokens: 7 } }, + }) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'aborted', reason: 'cancelled during max-tokens post-step' } }, + }) + }) + + it('closes the successful step as disposed when disposal lands during post-step', async () => { + const adapter = new FailureScriptAdapter([ + toolCallResponse('dispose-call', 'work', {}), + textResponse('must not continue'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'work', + description: 'do work', + parameters: {}, + async execute() { return [{ type: 'text', text: 'worked' }] }, + })) + const agent = ctx.agentLoop.create(SessionId('dispose-post-step'), { provider: 'mock', model: 'mock' }) + let entered!: () => void + const postStepEntered = new Promise((resolve) => { entered = resolve }) + ctx.on('agent/post-step', async (_agent, turn, step, signal) => { + expect({ turn, step }).toEqual({ turn: 1, step: 1 }) + entered() + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }) + + send(agent) + await postStepEntered + await ctx.fiber.dispose() + + expect(adapter.requests).toHaveLength(1) + const boundaries = agent.session.events.filter(event => + event.type === 'step/start' || event.type === 'step/end', + ) + expect(boundaries.map(event => event.type)).toEqual(['step/start', 'step/end']) + expect(boundaries.map(event => event.data)).toEqual([ + { turn: 1, step: 1 }, + { turn: 1, step: 1 }, + ]) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'disposed' } }, + }) + }) + + it.each([ + ['thrown', contextError()], + ['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]], + ] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => { + const adapter = new FailureScriptAdapter([failure, textResponse('recovered')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' }) + const attempts: number[] = [] + ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => { + expect(subject).toBe(agent) + expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE }) + attempts.push(attempt) + subject.session.append('context/message', { + content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }], + source: { kind: 'plugin', plugin: 'test-recovery' }, + }, { surfaceOp: 'append' }) + return { action: 'retry' } + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(attempts).toEqual([0]) + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('RECOVERY SURFACE MUTATION') + const starts = agent.session.events.filter(event => event.type === 'step/start') + const ends = agent.session.events.filter(event => event.type === 'step/end') + expect(starts.map(event => event.data.step)).toEqual([1, 2]) + expect(ends.map(event => event.data.step)).toEqual([1, 2]) + const recovery = agent.session.events.find(event => event.type === 'context/message')! + expect(ends[0]!.seq).toBeLessThan(recovery.seq) + expect(recovery.seq).toBeLessThan(starts[1]!.seq) + }) + + it.each(streamListenerFailureCases)('does not offer %s to request recovery', async (_name, install) => { + const ctx = await harness(new FailureScriptAdapter([textResponse('unused')])) + const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + install(ctx) + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(recoveries).toBe(0) + expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }) + + it('does not offer a nested model-call failure as the outer request failure', async () => { + const outer = new FailureScriptAdapter([textResponse('outer adapter must not run')]) + const nested = new FailureScriptAdapter([contextError('nested overflow')]) + const ctx = await harness(outer) + ctx.llm.registerAdapter(['nested'], nested) + ctx.on('llm/stream', (options, next) => { + if (options.provider !== 'mock') return next() + return (async function* () { + yield* ctx.llm.stream({ + provider: 'nested', + model: 'nested', + messages: [], + ...options.signal === undefined ? {} : { signal: options.signal }, + }) + yield* next() + })() + }) + const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(nested.requests).toHaveLength(1) + expect(outer.requests).toHaveLength(0) + expect(recoveries).toBe(0) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', message: 'nested overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + }) + }) + + it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)( + 'does not offer %s middleware failures to request recovery', + async (boundary) => { + const adapter = new FailureScriptAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + if (boundary === 'prompt-submit') { + ctx.on('agent/prompt-submit', () => { throw new Error('prompt submit failed') }) + } else if (boundary === 'prompt-assembly') { + ctx.on('system-prompt/assemble', () => { throw new Error('prompt assembly failed') }) + } else if (boundary === 'pre-step') { + ctx.on('agent/pre-step', () => { throw new Error('pre-step failed') }) + } else { + ctx.on('agent/request', () => { throw new Error('request middleware failed') }) + } + const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(recoveries).toBe(0) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } }) + }, + ) + + it('does not offer result, tool, or post-step plugin failures to request recovery', async () => { + for (const failure of ['result', 'tool', 'post-step'] as const) { + const adapter = new FailureScriptAdapter([ + failure === 'tool' ? toolCallResponse(`call-${failure}`, 'work', {}) : textResponse('done'), + ...(failure === 'tool' ? [textResponse('done')] : []), + ]) + const ctx = await harness(adapter) + if (failure === 'result') ctx.on('agent/step-result', () => { throw new Error('result failed') }) + if (failure === 'post-step') ctx.on('agent/post-step', () => { throw new Error('post-step failed') }) + if (failure === 'tool') { + vi.spyOn(ctx.tools, 'execute').mockRejectedValue(new Error('tool service failed')) + } + const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' }) + let recoveries = 0 + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => { + recoveries += 1 + return next() + }) + send(agent) + await waitForIdle(ctx, agent) + expect(recoveries, failure).toBe(0) + } + }) + + it.each([ + ['synchronous dispatch', (error: Error) => new SynchronousDispatchFailureAdapter(error)], + ['done getter', (error: Error) => new IteratorResultGetterFailureAdapter('done', error)], + ['value getter', (error: Error) => new IteratorResultGetterFailureAdapter('value', error)], + ] as const)('preserves original Error identity for adapter %s', async (_name, makeAdapter) => { + const original = contextError(`${_name} overflow`) + const ctx = await harness(makeAdapter(original)) + const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' }) + let seen: Error | undefined + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + seen = error + return next() + }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(seen).toBe(original) + }) + + it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => { + for (const scenario of ['iterator', 'no-adapter'] as const) { + const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness() + const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' }) + let seen = '' + ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => { + seen = error.code ?? '' + return next() + }) + send(agent) + await waitForIdle(ctx, agent) + expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER') + } + }) + + it('tracks consecutive retry attempts and resets after a successful request', async () => { + const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')]) + const cappedCtx = await harness(capped) + const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' }) + const cappedAttempts: number[] = [] + cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => { + cappedAttempts.push(attempt) + return attempt < 1 ? { action: 'retry' } : next() + }) + send(cappedAgent) + await waitForIdle(cappedCtx, cappedAgent) + expect(cappedAttempts).toEqual([0, 1]) + + const reset = new FailureScriptAdapter([ + contextError('first overflow'), + toolCallResponse('retry-reset-call', 'work', {}), + contextError('later overflow'), + ]) + const resetCtx = await harness(reset) + resetCtx.tools.register(defineTool({ + name: 'work', + description: 'continue', + parameters: {}, + async execute() { return [{ type: 'text', text: 'worked' }] }, + })) + const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' }) + const resetAttempts: { step: number; attempt: number }[] = [] + resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => { + resetAttempts.push({ step, attempt }) + return resetAttempts.length === 1 ? { action: 'retry' } : next() + }) + send(resetAgent) + await waitForIdle(resetCtx, resetAgent) + expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }]) + }) + + it('preserves the original provider error when recovery throws', async () => { + const adapter = new FailureScriptAdapter([contextError('original overflow')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('recovery-throws'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', () => { throw new Error('recovery exploded') }) + + send(agent) + await waitForIdle(ctx, agent) + + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } }, + }) + }) + + it.each(['cancel', 'dispose'] as const)('keeps %s live through request recovery', async (action) => { + const adapter = new FailureScriptAdapter([contextError()]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' }) + let entered!: () => void + const recoveryEntered = new Promise((resolve) => { entered = resolve }) + ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => { + entered() + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return { action: 'retry' } + }) + + send(agent) + const idle = waitForIdle(ctx, agent) + await recoveryEntered + if (action === 'cancel') { + agent.cancel('cancelled during recovery') + await idle + } else { + await ctx.fiber.dispose() + } + + expect(adapter.requests).toHaveLength(1) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: action === 'cancel' ? { kind: 'aborted', reason: 'cancelled during recovery' } : { kind: 'disposed' } }, + }) + }) +}) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index aa131009c6..74192dde90 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,9 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -50,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise { return root } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -83,11 +84,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => throwUnknown(failure)) await expect(ctx.agents.resume({ - agentId: AgentId('unknown-resume-failure'), resumeSessionId: sessionId, })).rejects.toBe(failure) - expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) @@ -95,27 +95,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() }) - it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { + it('createAgent rejects a duplicate identity without orphaning a session', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) - // A second create with the SAME agent id but a fresh session id must reject - // up front — and must NOT leave an orphaned 'sess-b' session behind. - await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/) - expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() + const sessionId = SessionId('sess-a') + await ctx.agents.create({ sessionId }) + await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/) + expect(ctx.sessions.list()).toHaveLength(1) await ctx.fiber.dispose() }) it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -125,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -141,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -152,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -171,7 +170,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) - await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') }) + await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() }) @@ -186,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session) + expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', (agent) => { @@ -199,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) const resuming = ctx.agents.resume({ - agentId: AgentId('resumed-atomic'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic')) + expect(agentCtx.agent?.id).toBe(sessionId) expect(agentCtx.agent?.session.events).toHaveLength(2) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) @@ -215,7 +213,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) await setupStarted.promise - expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() expect(order).toEqual(['setup:start']) @@ -236,17 +234,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('successful resume disposal retires its caller-owned transaction effects', async () => { const sessionId = SessionId('resume-retired-effects-s') - const agentId = AgentId('resume-retired-effects') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const handle = await ctx.agents.resume({ - agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) const transactionLabels = [ - `agentLoop.owner(${agentId})`, - `agentLoop.lifecycle(${agentId})`, + `agentLoop.owner(${sessionId})`, + `agentLoop.lifecycle(${sessionId})`, ] expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels)) @@ -255,7 +251,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { + it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => { const sessionId = SessionId('resume-setup-reject') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) @@ -265,7 +261,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -275,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { })).rejects.toThrow('resume setup failed') expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() const retry = await ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -299,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ - agentId: AgentId('resume-owner-race'), resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -313,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await owner.dispose() await expect(resuming).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() gate.resolve(undefined) @@ -322,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') - const agentId = AgentId('resume-load-race') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) @@ -348,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() // owner.dispose() awaited transaction settlement, so the same identities // can be reused before awaiting the public rejection. - const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) + const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })) await rejection expect(loads).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -370,7 +362,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() await Promise.resolve() - expect(ctx.agents.get(agentId)).toBe(retry.agent) + expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -380,7 +372,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') - const agentId = AgentId('resume-load-factory-race') const root = await persistSession(sessionId) const ctx = new Context() await ctx.plugin(LlmService) @@ -404,14 +395,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) - const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() @@ -452,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -464,7 +455,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // clean disposal follows, so disk presence proves its own checkpoint ran. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -487,7 +478,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // survive persistence and resume. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -505,7 +496,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -515,7 +506,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -535,7 +526,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -563,7 +554,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) + await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 472cb8cd8a..73ee48f7a6 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -4,10 +4,11 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' + import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -27,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' return (await harnessWithLoop(adapter)).ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -57,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { - it('rejects an already-aborted creation signal before publishing either identity', async () => { + it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') const controller = new AbortController() controller.abort(reason) await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted'), sessionId: SessionId('pre-aborted-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() const valueController = new AbortController() valueController.abort('plain cancellation reason') await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted-value'), sessionId: SessionId('pre-aborted-value-s'), signal: valueController.signal, })).rejects.toMatchObject({ - message: 'agent "pre-aborted-value" creation aborted', + message: 'agent "pre-aborted-value-s" creation aborted', cause: 'plain cancellation reason', }) - expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -100,12 +99,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('prepare-abort'), sessionId: SessionId('prepare-abort-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined() + expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -124,7 +122,7 @@ describe('agent scope lifecycle', () => { thrown = createFailure let createCaught: unknown try { - ctx.agentLoop.create(AgentId('unknown-create')) + ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } @@ -133,28 +131,45 @@ describe('agent scope lifecycle', () => { const ownedFailure = { source: 'createAgent' } thrown = ownedFailure await expect(ctx.agents.create({ - agentId: AgentId('unknown-owned-create'), sessionId: SessionId('unknown-owned-create-s'), })).rejects.toBe(ownedFailure) - expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined() - expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(AgentId('a1'))?.whenIdle() + await ctx.agents.get(SessionId('a1'))?.whenIdle() + }) + + it('records agents created through an agent context as non-root runtime children', async () => { + const ctx = await harness() + const root = await ctx.agents.create({ + sessionId: SessionId('runtime-root'), + agentOptions: { model: 'mock' }, + }) + const child = await root.agent.ctx.agents.create({ + sessionId: SessionId('runtime-child'), + agentOptions: { model: 'mock' }, + }) + + expect(ctx.agents.list()).toEqual([root.agent, child.agent]) + expect(ctx.agents.roots()).toEqual([root.agent]) + + await child.dispose() + await root.dispose() }) it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register({ @@ -179,8 +194,8 @@ describe('agent scope lifecycle', () => { it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) - const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' }) - const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' }) + const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) @@ -210,7 +225,6 @@ describe('agent scope lifecycle', () => { }) const handle = await ctx.agents.create({ - agentId: AgentId('child'), sessionId: SessionId('child-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async (agentCtx) => { @@ -224,14 +238,14 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('keeps both identities unpublished until async setup completes, then announces in order', async () => { + it('keeps both objects unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const order: string[] = [] ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session) + expect(ctx.agents.get(session.id)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', () => void order.push('agent/created')) @@ -239,11 +253,10 @@ describe('agent scope lifecycle', () => { const acceptedOptions = { provider: 'mock', model: 'mock' } const creating = ctx.agents.create({ - agentId: AgentId('atomic'), - sessionId: SessionId('atomic-s'), + sessionId: SessionId('atomic'), agentOptions: acceptedOptions, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('atomic')) + expect(agentCtx.agent?.id).toBe(SessionId('atomic')) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -253,8 +266,8 @@ describe('agent scope lifecycle', () => { }, }) await setupStarted.promise - expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() - expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) const handle = await creating @@ -281,16 +294,14 @@ describe('agent scope lifecycle', () => { if (started === 2) bothStarted.resolve(undefined) await gate.promise } - const agentId = AgentId('concurrent-final-enter') + const sessionId = SessionId('concurrent-final-enter') const first = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-a'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) const second = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-b'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup, }) @@ -304,7 +315,7 @@ describe('agent scope lifecycle', () => { const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) - expect(String(rejected[0]!.reason)).toMatch(/already registered/) + expect(String(rejected[0]!.reason)).toMatch(/already exists/) expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) @@ -318,7 +329,6 @@ describe('agent scope lifecycle', () => { const pendingController = new AbortController() const setupStarted = Promise.withResolvers() const pending = ctx.agents.create({ - agentId: AgentId('signal-pending'), sessionId: SessionId('signal-pending-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: pendingController.signal, @@ -330,12 +340,11 @@ describe('agent scope lifecycle', () => { await setupStarted.promise pendingController.abort(new Error('cancel pending creation')) await expect(pending).rejects.toThrow('cancel pending creation') - expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined() + expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() const liveController = new AbortController() const live = await ctx.agents.create({ - agentId: AgentId('signal-live'), sessionId: SessionId('signal-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, signal: liveController.signal, @@ -358,7 +367,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('owner-race'), sessionId: SessionId('owner-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -372,7 +380,7 @@ describe('agent scope lifecycle', () => { await owner.dispose() await expect(creating).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() // Let the losing callback settle; Promise.race already observes it. gate.resolve(undefined) @@ -386,7 +394,6 @@ describe('agent scope lifecycle', () => { let creating2!: ReturnType const owner2 = await ctx.plugin(Object.assign((inner: Context) => { creating2 = inner.agents.create({ - agentId: AgentId('owner-race-2'), sessionId: SessionId('owner-race-s-2'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -400,7 +407,7 @@ describe('agent scope lifecycle', () => { const unload2 = owner2.dispose() await expect(creating2).rejects.toThrow(/owner disposed during setup/) await unload2 - expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) @@ -413,7 +420,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) const creating = ctx.agents.create({ - agentId: AgentId('factory-setup-race'), sessionId: SessionId('factory-setup-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -426,7 +432,7 @@ describe('agent scope lifecycle', () => { await loopFiber.dispose() await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() gate.resolve(undefined) @@ -444,7 +450,6 @@ describe('agent scope lifecycle', () => { }) const creating = ctx.agents.create({ - agentId: AgentId('factory-scope-race'), sessionId: SessionId('factory-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: () => { setupCalls += 1 }, @@ -452,7 +457,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(0) - expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -479,7 +484,6 @@ describe('agent scope lifecycle', () => { const owner = ctx.plugin(Object.assign((inner: Context) => { ownerFiber = inner.fiber creating = inner.agents.create({ - agentId: AgentId('caller-scope-race'), sessionId: SessionId('caller-scope-race-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -495,7 +499,7 @@ describe('agent scope lifecycle', () => { await ownerDisposal await owner expect(scopeFiber?.uid).toBeNull() - expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() await owner.dispose() await ctx.fiber.dispose() @@ -511,17 +515,17 @@ describe('agent scope lifecycle', () => { void loopFiber.dispose() }) - expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { provider: 'mock', model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' })) .toThrow(/agent loop is not active/) await loopFiber.dispose() - expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) await ctx.fiber.dispose() }) it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() - const id = AgentId('config-prepare-failure') + const id = SessionId('config-prepare-failure') expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' })) .toThrow(/absolute path/) @@ -542,12 +546,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('factory-scope-throw'), sessionId: SessionId('factory-scope-throw-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('scope preparation failed') await loopFiber.dispose() - expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -556,23 +559,21 @@ describe('agent scope lifecycle', () => { it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { const { ctx, loopFiber } = await harnessWithLoop() const loop = ctx.agentLoop - const agentId = AgentId('factory-live') + const sessionId = SessionId('factory-live') const handle = await ctx.agents.create({ - agentId, sessionId: SessionId('factory-live-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) await loopFiber.dispose() expect(handle.agent.status).toBe('disposed') - expect(ctx.agents.get(agentId)).toBeUndefined() - expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() await expect(loop.createAgent(ctx, { - agentId: AgentId('factory-inactive'), sessionId: SessionId('factory-inactive-s'), })).rejects.toThrow('agent loop is not active') await ctx.fiber.dispose() @@ -583,7 +584,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('dependency-origin'), sessionId: SessionId('dependency-origin-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { @@ -623,7 +623,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return - const agent = ctx.agents.get(AgentId('session-created-barrier'))! + const agent = ctx.agents.get(SessionId('session-created-barrier-s'))! expect(ctx.sessions.get(session.id)).toBe(session) expect(agent.session).toBe(session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) @@ -638,7 +638,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-created-barrier'), sessionId: SessionId('session-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -652,7 +651,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -666,19 +665,19 @@ describe('agent scope lifecycle', () => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) ctx.on('agent/disposed', (agent) => { - if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed') + if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') @@ -687,7 +686,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('agent-created-barrier'), sessionId: SessionId('agent-created-barrier-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -703,7 +701,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -715,13 +713,12 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/created', (agent) => { - if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose() + if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose() }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('listener-dispose'), sessionId: SessionId('listener-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -730,7 +727,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(starts).toEqual([]) - expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -739,20 +736,20 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType - let announced!: ReactLoopAgent + let announced!: Agent const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false ctx.on('agent/status', (agent, status) => { - if (agent.id === AgentId('session-start-dispose')) statuses.push(status) + if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return - announced = agent as ReactLoopAgent + if (agent.id !== SessionId('session-start-dispose-s')) return + announced = agent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { scopeDisposed = true }) @@ -762,7 +759,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-start-dispose'), sessionId: SessionId('session-start-dispose-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -775,7 +771,7 @@ describe('agent scope lifecycle', () => { expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) expect(announced.session.events).toEqual([]) - expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -787,7 +783,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup: async () => { @@ -798,13 +793,13 @@ describe('agent scope lifecycle', () => { // Nothing leaked: no agent, no session, and the ids are reusable. expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) await retry.dispose() }) - it('rejects an exotic durable seed before publishing either identity', async () => { + it('rejects an exotic durable seed before publishing either object', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) @@ -817,17 +812,15 @@ describe('agent scope lifecycle', () => { }] as unknown as SessionEvent[] await expect(ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, seed, })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() const retry = await ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -843,13 +836,13 @@ describe('agent scope lifecycle', () => { if (boom) { boom = false; throw new Error('boom created') } }) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('boom created') - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) @@ -866,18 +859,17 @@ describe('agent scope lifecycle', () => { ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ - agentId: AgentId('partial-agent'), sessionId: SessionId('partial-session'), agentOptions: { provider: 'mock', model: 'mock' }, })).rejects.toThrow('agent observer failed') expect(lifecycle).toEqual([ 'session-created:partial-session', - 'agent-created:partial-agent', - 'agent-disposed:partial-agent', + 'agent-created:partial-session', + 'agent-disposed:partial-session', 'session-disposed:partial-session', ]) - expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined() + expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) @@ -892,23 +884,23 @@ describe('agent scope lifecycle', () => { } }) - expect(() => ctx.agentLoop.create(AgentId('config-bad'), { provider: 'mock', model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' })) .toThrow('config publish failed') - expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) }) it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) @@ -921,7 +913,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle @@ -930,7 +922,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/end') order.push('turn-end') }) ctx.on('agent/disposed', () => { - order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`) + order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`) order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) }) @@ -953,7 +945,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] @@ -965,23 +957,22 @@ describe('agent scope lifecycle', () => { // actually finished (the raw wrapper returns undefined on a repeat call). await handle.dispose() expect(teardownDone).toContain('unregistered') - expect(ctx.agents.get(AgentId('h1'))).toBeUndefined() + expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() - const agentId = AgentId('retired-owner-effect') + const sessionId = SessionId('retired-owner-effect') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('retired-owner-effect-s'), + sessionId, agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) await ctx.fiber.dispose() }) @@ -992,7 +983,6 @@ describe('agent scope lifecycle', () => { let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ - agentId: AgentId('manual-first'), sessionId: SessionId('manual-first-s'), agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { @@ -1012,7 +1002,7 @@ describe('agent scope lifecycle', () => { expect(ownerSettled).toBe(false) gate.resolve(undefined) await Promise.all([disposing, unloading]) - expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined() + expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -1022,13 +1012,11 @@ describe('agent scope lifecycle', () => { const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionDisposed = Promise.withResolvers() - const agentId = AgentId('quiescent-reuse') - const sessionId = SessionId('quiescent-reuse-s') + const sessionId = SessionId('quiescent-reuse') ctx.on('session/disposed', (session) => { if (session.id === sessionId) sessionDisposed.resolve(undefined) }) const first = await ctx.agents.create({ - agentId, sessionId, agentOptions: { provider: 'mock', model: 'mock' }, setup(agentCtx) { @@ -1041,10 +1029,10 @@ describe('agent scope lifecycle', () => { const disposing = first.dispose() await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) - expect(ctx.agents.get(agentId)).toBe(replacement.agent) + const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + expect(ctx.agents.get(sessionId)).toBe(replacement.agent) expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) gate.resolve(undefined) @@ -1056,7 +1044,6 @@ describe('agent scope lifecycle', () => { it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { const ctx = await harness() const handle = await ctx.agents.create({ - agentId: AgentId('idle-flush'), sessionId: SessionId('idle-flush-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -1075,12 +1062,12 @@ describe('agent scope lifecycle', () => { const disposal = handle.dispose().then(() => { disposed = true }) await new Promise(resolve => setTimeout(resolve, 0)) expect(disposed).toBe(false) - expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent) expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) gate.resolve(undefined) await disposal - expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts new file mode 100644 index 0000000000..638f7fa8c7 --- /dev/null +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -0,0 +1,588 @@ +/** + * Exercises scheduler ordering and cancellation with deterministic gated tools. + * ACP goldens own transcript-facing coverage. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import LlmService from '@deepseek-ai/dsh-llm' +import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { MockAdapter, textResponse } from './mock-adapter.ts' + +async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { + agents: [], + ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls }, + }) + ctx.llm.registerAdapter(['mock'], adapter) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +function events(agent: Agent): SessionEvent[] { + return [...agent.session.events] +} + +/** Build one assistant response containing the supplied tool calls. */ +function multiCall(calls: { id: string; name: string; args: object }[]): StreamChunk[] { + const chunks: StreamChunk[] = [] + calls.forEach((call, index) => { + chunks.push( + { type: 'block-start', index, blockType: 'tool-call' }, + { type: 'block-end', index, block: { type: 'tool-call', id: CallId(call.id), name: call.name, arguments: JSON.stringify(call.args) } }, + ) + }) + chunks.push( + { type: 'usage', usage: { inputTokens: 5, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ) + return chunks +} + +/** A tool whose calls block until the test releases them by callId. */ +function gatedTool(name: string, parallel: boolean) { + const gates = new Map void>() + const started: string[] = [] + const tool = defineTool({ + name, + description: `gated ${name}`, + parameters: { id: { type: 'string', required: true } }, + ...parallel ? { isConcurrencySafe: () => true } : {}, + async execute(args) { + started.push(args.id) + await new Promise((resolve) => { gates.set(args.id, resolve) }) + return [{ type: 'text', text: `done-${args.id}` }] + }, + }) + return { + tool, + started, + release(id: string) { gates.get(id)?.(); gates.delete(id) }, + pending() { return [...gates.keys()] }, + } +} + +function gatedParallelTool(name: string) { + return gatedTool(name, true) +} + +function gatedExclusiveTool(name: string) { + return gatedTool(name, false) +} + +/** Poll until `predicate` holds, letting microtasks/timers drain between checks. */ +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 1000 && !predicate(); i++) await new Promise(r => setTimeout(r, 0)) + if (!predicate()) throw new Error('until: condition never held') +} + +describe('tool-call scheduler: grouping and barriers', () => { + it('runs parallel-safe siblings concurrently (all start before any completes)', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 3) + expect(gated.started).toEqual(['1', '2', '3']) + gated.release('1'); gated.release('2'); gated.release('3') + await waitForIdle(ctx, agent) + }) + + it('an exclusive call between two parallel-safe calls forms a barrier (3 groups)', async () => { + const order: string[] = [] + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'r', args: { id: 'A1' } }, + { id: 'c2', name: 'w', args: { id: 'A2' } }, + { id: 'c3', name: 'r', args: { id: 'A3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + ctx.tools.register(defineTool({ + name: 'r', description: 'read', parameters: { id: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute(args) { order.push(`r-start-${args.id}`); order.push(`r-end-${args.id}`); return [{ type: 'text', text: 'r' }] }, + })) + ctx.tools.register(defineTool({ + name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } }, + async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(order).toEqual(['r-start-A1', 'r-end-A1', 'w-A2', 'r-start-A3', 'r-end-A3']) + }) + + it('reclassifies pending calls after an exclusive barrier replaces their tool', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'replace', args: { id: '0' } }, + { id: 'c2', name: 'x', args: { id: '1' } }, + { id: 'c3', name: 'x', args: { id: '2' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const replacement = gatedExclusiveTool('x') + const disposeSafe = ctx.tools.register(defineTool({ + name: 'x', + description: 'initially safe', + parameters: { id: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute(args) { return [{ type: 'text', text: `old-${args.id}` }] }, + })) + ctx.tools.register(defineTool({ + name: 'replace', + description: 'replace x', + parameters: { id: { type: 'string', required: true } }, + async execute() { + disposeSafe() + ctx.tools.register(replacement.tool) + return [{ type: 'text', text: 'replaced' }] + }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => replacement.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(replacement.started).toEqual(['1']) + replacement.release('1') + await until(() => replacement.started.length === 2) + expect(replacement.started).toEqual(['1', '2']) + replacement.release('2') + await waitForIdle(ctx, agent) + }) + + it('stops replenishing when a result observer makes the next call exclusive', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'x', args: { id: '1' } }, + { id: 'c2', name: 'x', args: { id: '2' } }, + { id: 'c3', name: 'x', args: { id: '3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter, 2) + const initial = gatedParallelTool('x') + const replacement = gatedExclusiveTool('x') + const disposeInitial = ctx.tools.register(initial.tool) + ctx.on('tools/result', (exec) => { + if (exec.callId !== CallId('c1')) return + disposeInitial() + ctx.tools.register(replacement.tool) + }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => initial.started.length === 2) + initial.release('1') + await until(() => events(agent).some(event => + event.type === 'tool/result' && event.data.callId === CallId('c1'))) + await new Promise(r => setTimeout(r, 5)) + expect(replacement.started).toEqual([]) + initial.release('2') + await until(() => replacement.started.length === 1) + expect(replacement.started).toEqual(['3']) + replacement.release('3') + await waitForIdle(ctx, agent) + }) +}) + +describe('tool-call scheduler: model-order results despite out-of-order settlement', () => { + it('commits tool/result in model order even when a later call settles first', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + gated.release('2') + await new Promise(r => setTimeout(r, 5)) + const beforeFirst = events(agent).filter(e => e.type === 'tool/result') + expect(beforeFirst).toEqual([]) + gated.release('1') + await waitForIdle(ctx, agent) + + const results = events(agent).filter(e => e.type === 'tool/result') + expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2')]) + }) + + it('derived history pairs calls in model order regardless of tool/call log interleaving', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + gated.release('2'); gated.release('1') + await waitForIdle(ctx, agent) + + const messages = agent.session.deriveMessages() + const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result')) + expect(toolResults.map(b => b.toolCallId)).toEqual([CallId('c1'), CallId('c2')]) + }) +}) + +describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => { + it('rejects invalid global maxParallelToolCalls config at plugin load', async () => { + await expect(harness(new MockAdapter([]), 0)).rejects.toThrow() + await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() + }) + + it('defensively rejects invalid caps when direct construction bypasses the config schema', () => { + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 })) + .toThrow('maxParallelToolCalls must be a positive integer') + expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 })) + .toThrow('maxParallelToolCalls must be a positive integer') + }) + + it('defaults the cap when direct construction bypasses the config schema', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + + expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow() + await ctx.fiber.dispose() + }) + + it('starts at most the cap, replenishing as calls settle', async () => { + const adapter = new MockAdapter([ + multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), + textResponse('done'), + ]) + const ctx = await harness(adapter, 2) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1', '2']) + gated.release('1') + await until(() => gated.started.length === 3) + expect(gated.started).toEqual(['1', '2', '3']) + expect(events(agent) + .filter(e => e.type === 'tool/call' || e.type === 'tool/result') + .map(e => `${e.type}:${String(e.data.callId)}`) + .slice(0, 4)) + .toEqual(['tool/call:c1', 'tool/call:c2', 'tool/result:c1', 'tool/call:c3']) + gated.release('2'); gated.release('3') + await until(() => gated.started.length === 4) + gated.release('4') + await waitForIdle(ctx, agent) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) + }) + + it('maxParallelToolCalls: 1 is fully serial (no second start before the first settles)', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter, 1) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await until(() => gated.started.length === 2) + gated.release('2') + await waitForIdle(ctx, agent) + }) + + it('applies the configured cap to every factory-created agent', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) + ctx.llm.registerAdapter(['mock'], adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await until(() => gated.started.length === 2) + gated.release('2') + await waitForIdle(ctx, agent) + }) + +}) + +describe('tool-call scheduler: ordered middleware and additional contexts', () => { + it('tools/pre-execute and tools/post-execute observe model call order', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }, { id: 'c3', name: 'p', args: { id: '3' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const pre: string[] = [] + const post: string[] = [] + ctx.on('tools/pre-execute', async (exec, next): Promise => { pre.push(String(exec.callId)); return next() }) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { post.push(String(exec.callId)); return next() }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 3) + gated.release('3'); gated.release('2'); gated.release('1') + await waitForIdle(ctx, agent) + + expect(pre).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String)) + expect(post).toEqual([CallId('c1'), CallId('c2'), CallId('c3')].map(String)) + }) + + it('injects additional contexts in model call order, not settlement order', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = await harness(adapter, 2) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + ctx.on('tools/post-execute', async (exec, _result): Promise => + ({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + gated.release('2'); gated.release('1') + await waitForIdle(ctx, agent) + + const log = events(agent) + const contextTexts = log.filter(e => e.type === 'context/message') + .map(e => (e.data.content[0] as { text: string }).text) + expect(contextTexts).toEqual(['ctx-c1', 'ctx-c2']) + const lastResult = log.findLastIndex(e => e.type === 'tool/result') + const firstContext = log.findIndex(e => e.type === 'context/message') + expect(lastResult).toBeLessThan(firstContext) + }) + + it('orders pre-execute denials and errors without dispatching them', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'p', args: { id: '1' } }, + { id: 'c2', name: 'p', args: { id: '2' } }, + { id: 'c3', name: 'p', args: { id: '3' } }, + ]), + textResponse('done'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const post: string[] = [] + ctx.on('tools/pre-execute', async (exec, next): Promise => { + if (exec.callId === CallId('c2')) return { kind: 'deny', reason: 'blocked by policy' } + if (exec.callId === CallId('c3')) throw new Error('pre exploded') + return next() + }) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => { + post.push(String(exec.callId)) + return next() + }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + gated.release('1') + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual(['1']) + expect(post).toEqual(['c1', 'c2']) + const results = events(agent).filter(e => e.type === 'tool/result') + expect(results.map(e => e.data.callId)).toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) + expect((results[1]!.data.content[0] as { text: string }).text).toContain('blocked by policy') + expect((results[2]!.data.content[0] as { text: string }).text).toContain('pre exploded') + }) +}) + +describe('tool-call scheduler: abort handling', () => { + it('starts no calls when the signal is already aborted before a parallel group', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/message') { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('already aborted') + } + }) + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual([]) + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({ + callId: e.data.callId, + isError: e.data.isError, + error: e.data.error, + }))).toEqual([ + { callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + { callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }, + ]) + }) + + it('stops starting siblings when abort fires during ordered pre-execute', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + ctx.on('tools/pre-execute', async (exec, next): Promise => { + if (exec.callId === CallId('c1')) { + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('pre cancelled') + } + return next() + }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await waitForIdle(ctx, agent) + + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2')]) + expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) + .toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + }) + + it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => { + const adapter = new MockAdapter([ + multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter, 2) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + ctx.on('tools/post-execute', async (exec, _result, next): Promise => ({ + ...await next(), + additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }], + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop now') + gated.release('1') + gated.release('2') + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual(['1', '2']) + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) + expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')]) + expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data)) + .toEqual([ + expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }), + ]) + const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message') + expect(settled.map(e => e.type)) + .toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message']) + expect(settled.filter(e => e.type === 'context/message') + .map(e => (e.data.content[0] as { text: string }).text)) + .toEqual(['ctx-c1', 'ctx-c2']) + }) + + it('does not run an exclusive barrier after a parallel group aborts', async () => { + const adapter = new MockAdapter([ + multiCall([ + { id: 'c1', name: 'p', args: { id: '1' } }, + { id: 'c2', name: 'p', args: { id: '2' } }, + { id: 'c3', name: 'x', args: { id: '3' } }, + ]), + textResponse('should never be requested'), + ]) + const ctx = await harness(adapter, 2) + const gated = gatedParallelTool('p') + const exclusive: string[] = [] + ctx.tools.register(gated.tool) + ctx.tools.register(defineTool({ + name: 'x', + description: 'exclusive', + parameters: { id: { type: 'string', required: true } }, + async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] }, + })) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 2) + ;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('stop before barrier') + gated.release('1') + gated.release('2') + await waitForIdle(ctx, agent) + + expect(exclusive).toEqual([]) + expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) + .toEqual([CallId('c1'), CallId('c2'), CallId('c3')]) + expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data) + .toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }) + }) +}) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 940b7fdabe..bf78208a42 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -9,12 +9,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { @@ -29,7 +30,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -56,7 +57,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } @@ -98,7 +99,7 @@ describe('loop-level canonical tool order', () => { registerNamed(ctx, 'alpha') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 5e979988d7..355e1e8e3d 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' + +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -22,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function send(agent: ReactLoopAgent, text = 'go'): Promise { +function send(agent: Agent, text = 'go'): Promise { agent.send([{ type: 'text', text }]) return agent.whenIdle() } @@ -45,7 +46,7 @@ describe('agent/turn-stop', () => { textResponse('must not be requested'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let steered = false @@ -72,7 +73,7 @@ describe('agent/turn-stop', () => { textResponse('must not become a late-steering turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let injected = false @@ -98,7 +99,7 @@ describe('agent/turn-stop', () => { textResponse('queued follow-up answer'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { provider: 'mock', model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let queued = false @@ -124,8 +125,8 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const stopped = ctx.agentLoop.create(AgentId('stopped'), { provider: 'mock', model: 'mock' }) - const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { provider: 'mock', model: 'mock' }) + const stopped = ctx.agentLoop.create(SessionId('stopped'), { provider: 'mock', model: 'mock' }) + const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { provider: 'mock', model: 'mock' }) stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(stopped) @@ -145,7 +146,7 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('owned-listener'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-listener'), { provider: 'mock', model: 'mock' }) const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(agent, 'first turn') @@ -162,7 +163,7 @@ describe('agent/turn-stop', () => { textResponse('healthy later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('bad-policy'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-policy'), { provider: 'mock', model: 'mock' }) const reasons: TurnEndReason[] = [] const errors: string[] = [] ctx.on('session/event', (session, event) => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 1255731484..372b594eb7 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -1,37 +1,50 @@ # dsh-agent -Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable. +Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable. ## Service: `AgentRegistry` (ctx key: `agents`) -Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package. +Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package. ### Public API -`Agent.ctx` owns registrations visible only to that agent. `agentEvents()` couples event subjects to their scope carrier, and `assembleContextFor()` couples the agent and prompt scope. Creation and resume may compose this context through `setup`; the agent remains unpublished and must not be driven until creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`. -- `ctx.agents.get(id: AgentId): Agent | undefined` +- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- `ctx.agents.get(id: SessionId): Agent | undefined` +- `ctx.agents.isOwnedBy(id: SessionId, owner: Agent): boolean` — whether the exact live entry was created through that parent agent's scoped context; runtime ownership is independent of durable session lineage. - `ctx.agents.list(): Agent[]` +- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. + +#### Initiating Agent scope + +`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated: a child driver's continuations carry the child, while the parent continuation regains the parent as soon as `withInitiator()` returns; drain tracking continues until the child driver's Promise settles. Creation, persistence load, and unpublished setup remain outside the child's boundary, so setup initiated by a parent inherits the parent while `agentCtx.agent` identifies the child explicitly. + +- `ctx.agents.currentInitiator(): Agent | undefined` — read the inherited initiator without requiring one. +- `ctx.agents.requireInitiator(): Agent` — read it or throw `no initiating agent is active`. +- `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise. +- `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work. + +The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract. #### Factory seam (creation) -The loop plugin registers `AgentFactory`, keeping consumers independent of its concrete package. Each call is traced through the caller's context so the caller owns the resulting transaction and handle. +Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options)` creates and composes an unpublished session and agent, then atomically enters the registries and starts the loop. A creation-only signal cancels before publication; same-ID contenders arbitrate at entry and losers roll back. -- `ctx.agents.resume(options)` loads a persisted session and follows the same composition and publication boundary. It requires [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. -`AgentHandle = { agent, dispose }` is the consumer teardown capability; registry observers receive only the bare agent. Disposal stops and drains the loop and idle-injection flushes before unregistering the agent, detaching its session, and unwinding its scope. Caller and factory unload share that memoized boundary. +`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. ### Live events `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. +The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves. -Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). +Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). `PromptDecision.additionalContexts` is an array so every injected context keeps its own source, envelope, and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source. @@ -58,18 +71,36 @@ The handle every plugin programs against: ### User, steering, and injected messages -**What the model sees**: `send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. +#### What the model sees -**Token effect**: Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent. +`send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. + +#### Token effect + +Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent. + +#### KV Cache effect + +Accepted history and steering are append-only; a blocked submission sends no request. A session prefix remains stable within its loop instance, while a new or resumed instance may establish a different prefix. ### Agent-scoped request composition -**What the model sees**: Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup. +#### What the model sees -**Token effect**: The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal. +Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup. + +#### Token effect + +The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal. + +#### KV Cache effect + +Prefix-stable while an agent's scoped registrations are unchanged. Setup or reload that changes prompt sections, tool definitions, or request listeners may invalidate reuse from the first affected request token. ## Known Limitations and Deferred Work +- **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly. +- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work. - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. - **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. - **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 72cd18942e..bcf75ea7ca 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent", - "description": "Agent interface, registry, and event vocabulary for the DeepSeek Harness", + "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index fc5ad371b4..75f00daebb 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -1,15 +1,18 @@ /** - * Agent registry service. Tracks live agents so plugins can find them without - * depending on the concrete loop package. Agent creation belongs to the loop. + * Agent service: live registry, factory delegation, and process-local + * initiator scope. Concrete creation and driving belong to the loop. * * @module @deepseek-ai/dsh-agent */ -import { Context, getTraceable, Service, symbols } from 'cordis' +import { Context, FiberState, getTraceable, Service, symbols } from 'cordis' +import type { Fiber } from 'cordis' +import { AsyncLocalStorage } from 'node:async_hooks' +import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentId, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -31,23 +34,57 @@ declare module 'cordis' { } } -/** Options for creating an agent and its caller-named session. */ +/** + * Options for programmatically creating an agent through the registry factory + * ({@link AgentRegistry.create}). The caller supplies the single live + * `sessionId` shared by the agent registry and session log (e.g. an + * ACP-generated id), plus optional session metadata (the validated `cwd`, fork + * lineage); the factory creates the session and agent under that identity. + */ export interface CreateAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The live session's id (NOT derived from agentId). */ + /** The live agent/session identity. */ readonly sessionId: SessionId - /** Durable session metadata, validated and detached before setup. */ + /** + * Session creation metadata: validated absolute `cwd`, `parentSession` + * fork lineage, and the `seedLength` seed boundary. Mirrors the + * `cwd`/`parentSession`/`seedLength` fields of + * {@link CreateSessionOptions.meta} in dsh-session (the internal-only + * `createdAt`, used when reconstructing a persisted session, is deliberately + * excluded — a factory caller never sets it). This is durable session data, + * so the session boundary validates and snapshots it before asynchronous + * setup begins. + */ readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } - /** Balanced contiguous event prefix for a forked session. */ + /** + * Seed events to reconstruct the child session's log from (the fork lineage + * primitive). When present, the factory creates the session with this event + * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the + * in-process FORK subagent backend to seed a child with a balanced + * completed-turn prefix of the parent's log. The prefix MUST be contiguous + * from seq 0, carry only lossless-JSON data, and be balanced (no open + * turn/step, no dangling tool-call), or the session constructor (and the + * dev-mode invariants replay) reject it. The factory passes the raw seed to + * the session's durable validator/snapshot boundary. Absent for a fresh + * (spawn) child. + */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */ readonly signal?: AbortSignal /** - * Compose the unpublished scoped context before lifecycle announcements. - * Failure rolls back without publishing either id; setup must not drive the agent. + * Creation-time composition of the agent's scoped world. The factory awaits + * setup after minting `agentCtx` but BEFORE inserting or announcing either + * the session or agent, so observers can never see a partially configured + * world. Everything registered through `agentCtx` (scoped tools, prompt + * sections/variables, `restrict()`, listeners, awaited child plugins) exists + * before `session/created`, `agent/created`, `agent/session-start`, and the + * first prompt assembly. A throw/rejection or owner disposal rolls the scope + * back without publishing either id. + * + * **Setup composes, it never drives**: the callback is trusted same-process + * code and receives the full scoped context, so this is a contract rather + * than a runtime restriction. Drive the agent only after creation resolves. */ readonly setup?: (agentCtx: Context) => Promise | void } @@ -57,23 +94,41 @@ export interface CreateAgentOptions { * ({@link AgentRegistry.resume}). */ export interface ResumeAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The persisted session id to load and resume on. */ + /** The persisted session id to load and use as the live agent/session identity. */ readonly resumeSessionId: SessionId /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ readonly signal?: AbortSignal - /** Compose after persistence load under the same unpublished rollback contract as create. */ + /** + * Resume-time composition of the agent's fresh scoped world. Persistence is + * loaded first; the factory then mints `agentCtx` and awaits setup while the + * reconstructed session and agent remain unpublished. The callback has the + * same trusted composition-only contract as + * {@link CreateAgentOptions.setup}: all registrations exist before either + * creation announcement, and rejection or owner disposal rolls the + * transaction back without publishing either id. + */ readonly setup?: (agentCtx: Context) => Promise | void } /** - * Holder-owned agent capability. Disposal stops and drains the loop and idle - * flushes before unregistering the agent, detaching its session, and unwinding - * its scoped context. Provider unload reaches the same quiescence boundary; - * registry observers receive only the bare {@link Agent}. + * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / + * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers, + * only the holder can tear this agent down. The registered factory provider is + * also a structural owner because the scoped agent depends on that provider's + * service surface; provider unload stops and drains every live handle it made. + * `dispose()` stops the loop, awaits its exit and every outstanding + * idle-injection flush (quiescence — NOT just the `disposed` + * status flip), unregisters the agent, removes its session from the store, and + * finally unwinds its scoped world. This order captures every agent-started + * `session/flush` before the session is detached and keeps scoped listeners + * alive through those checkpoints. + * + * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is + * exposed only to the consumer owner that created it; the structural provider + * reaches the same teardown internally. Config-created agents (the loop's own + * startup) are owned by the loop fiber and never need a handle. */ export interface AgentHandle { agent: Agent @@ -88,16 +143,30 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create and compose under caller ownership, publish and announce session then - * agent, emit session-start, and start the driver. Rollback pairs any creation - * announcement that began. + * Create a new agent on a caller-supplied session id. Async because creation + * awaits unpublished setup, inserts both session and agent, emits their + * creation notifications in order, emits `agent/session-start`, and only + * then starts the loop. The sequence is + * rollback-covered, but notifications delivered before a later listener + * failure remain observable; every agent or session creation announcement + * that began is paired by `agent/disposed` or `session/disposed` during + * rollback. The owner disposes the resolved handle to stop/drain, + * unregister, remove the session, and unwind the scope. + * The registry passes a context carrying the `create()` caller's fiber and + * scope as `ownerCtx`. The implementation attaches the unpublished + * transaction and resulting lifecycle to that owner; it must not infer + * ownership from the factory object's registration context. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Load, compose, publish, announce, and resume an agent under caller ownership. + * Load a persisted session and resume an agent on it. Async because it awaits + * both `ctx.sessionPersistence.load` and the optional unpublished setup + * transaction; must be called after that service exists (consumers inject + * `sessionPersistence`). Publication follows the same ordered boundary as + * {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -107,57 +176,160 @@ export interface AgentFactory { /** Thrown when create/resume is called before an agent factory is registered. */ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)' +const NO_INITIATOR_MESSAGE = 'no initiating agent is active' +const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed' /** All mutable lifecycle state for one exact registry entry. */ interface AgentEntry { - readonly id: AgentId + readonly id: SessionId readonly agent: Agent + /** Runtime creator-agent ownership; independent of durable session lineage. */ + readonly owner: Agent | undefined readonly carrier: Scoped announced: boolean announcing: boolean detachRequested: boolean } +/** One tracked boundary plus its inherited nesting chain. */ +interface InitiatorRun { + active: boolean + readonly parent: InitiatorRun | undefined +} + /** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */ interface FactorySlot { readonly target: AgentFactory } /** - * Agent registry (`ctx.agents`): tracks live agents so UI, hook, and - * orchestrator plugins can find them without depending on the concrete loop - * package. Agent *creation* is provided by whichever plugin implements the - * {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via - * {@link setFactory}. + * Agent service (`ctx.agents`): tracks live agents and carries the initiating + * Agent through one process-local asynchronous driver chain. Agent *creation* + * is provided by whichever plugin implements the {@link AgentFactory} + * (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}. + * + * Initiator methods provide same-process causal attribution only. Ambient + * presence is neither liveness proof nor authorization; subjects and owners + * remain explicit, as does identity at worker, process, persistence, and wire + * boundaries. Returned Promise boundaries drain during teardown, except a + * nested lineage that starts an owning-fiber unload is excluded from its own drain. */ export class AgentRegistry extends Service { - private store = new Map() - // TODO(agent-entry-mirror): derive exact-object checks from store.get(agent.id) - // plus entry.agent identity; this WeakMap mirrors the authoritative id map. - private entries = new WeakMap() + private store = new Map() private factory: FactorySlot | undefined + private readonly initiators = new AsyncLocalStorage() + private readonly initiatorRuns = new AsyncLocalStorage() + private initiatorState: 'active' | 'closing' | 'disposed' = 'active' + private activeInitiatorRuns = 0 + private initiatorDrain: PromiseWithResolvers | undefined + private initiatorDisposal: Promise | undefined constructor(ctx: Context) { super(ctx, 'agents') - // Agent contexts shadow this plain-context default with an own property. + // The `ctx.agent` DX accessor: default `undefined` on every context, so a + // plain plugin context reads cleanly instead of hitting the Cordis + // unknown-property throw. Each Agent.ctx shadows it with an own property + // (own properties resolve before the context proxy is consulted), so the + // accessor body never needs to resolve a scope itself. Effect-scoped: + // unwinds with this service's fiber. ctx.accessor('agent', { get: () => undefined }) + ctx.on('internal/status', (fiber) => { + if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) { + this.closeInitiators() + } + }) + ctx.effect(function* (this: AgentRegistry) { + yield () => this.disposeInitiators() + yield () => { this.closeInitiators() } + }.bind(this), 'agents.initiatorLifecycle()') } /** - * Register the effect-scoped creation factory, rejecting a duplicate. Service - * factories are retraced through each create/resume caller for ownership. + * Read the Agent that initiated the inherited asynchronous driver chain. + * Use this optional form for logging, tracing, metrics, or host attribution + * that also supports agentless calls. When a parent creates a child, setup + * reports the causal parent while `agentCtx.agent` identifies the child. + * @returns the inherited Agent, or `undefined` outside an initiator boundary + * and inside an explicit clearing boundary. + * @throws when this service instance has been disposed. + */ + currentInitiator(): Agent | undefined { + this.assertInitiatorsReadable() + return this.initiators.getStore() + } + + /** + * Read the initiating Agent and fail when no initiator boundary is active. + * Use this for private helpers contractually below a driver, or for a + * deployment-owned outbound request whose contract forbids agentless calls. + * Generic or direct-call seams use optional lookup or explicit request fields. + * @returns the inherited Agent. + * @throws when no initiator is active or this service instance has been disposed. + */ + requireInitiator(): Agent { + const agent = this.currentInitiator() + if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE) + return agent + } + + /** + * Run an operation with one exact Agent as its process-local initiator. The + * exact synchronous value or Promise returned by the operation is preserved. + * Custom drivers and test harnesses wrap their complete returned foreground + * lifetime. + * A queue or wire receiver may establish this boundary only after validating + * explicit identity and resolving the exact live Agent; this method does neither. + * Detached work remains owned by the subsystem that starts it. + * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization. + * @param operation - synchronous or asynchronous operation to invoke. + * @returns the exact value returned by `operation`. + * @throws when the initiator scope is closing/disposed, or when `operation` throws. + */ + withInitiator(agent: Agent, operation: () => T): T { + return this.runWithInitiator(agent, operation) + } + + /** + * Run an operation inside a boundary that hides any inherited initiating + * Agent. The exact synchronous value or Promise is preserved. + * Use this while creating lazy shared timers, queue pumps, pool maintenance, + * watchers, or exporters so they do not inherit the first Agent that happens + * to initialize them. It clears only initiator attribution, not explicit + * fields, and does not own or drain detached resources. + * @param operation - synchronous or asynchronous operation to invoke without an initiator. + * @returns the exact value returned by `operation`. + * @throws when the initiator scope is closing/disposed, or when `operation` throws. + */ + withoutInitiator(operation: () => T): T { + return this.runWithInitiator(undefined, operation) + } + + /** + * Register the agent-creation factory (the loop calls this on construction, + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. - * @returns the exact Cordis effect disposer. + * @returns the disposer that clears the factory slot. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - // Store the concrete service; calls are retraced through their owner. + // Avoid stacking two Cordis shadow layers when a caller passes a Service + // already read through a context. Calls are re-traced through their + // actual owner context below. const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory this.factory = { target } return () => { this.factory = undefined } }, 'agents.setFactory()') - // Return the exact disposer so composite effects preserve teardown order. + // The exact cordis effect disposer (the agents.register() convention): a + // caller's composite effect can yield it for in-order teardown; the + // loop's constructor effect returns it directly, identity-nesting the + // registration under that effect. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -169,14 +341,20 @@ export class AgentRegistry extends Service { } /** - * Create and publish an owned agent and session through the active factory. - * Rejects if no factory is registered or creation, setup, or publication fails. - * @param options - agent id, session id/seed/metadata, and agent options. + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { const ownerCtx = this.ctx - // Bind service effects to this caller while preserving factory dependencies. + // Re-trace a Service-backed factory through the accessing context + // explicitly. This preserves AgentLoop's dependency origin while binding + // its effects to ownerCtx; plain factories receive ownerCtx as an explicit + // capability and need no Cordis tracker magic. const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver @@ -199,14 +377,26 @@ export class AgentRegistry extends Service { } /** - * Register a live agent in the calling effect scope, with scope-filtered - * creation and disposal events. Duplicate ids throw. + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @returns the exact Cordis effect disposer for nested teardown ordering. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * returns undefined without awaiting an in-flight teardown). Exact + * identity is load-bearing: a composite (generator) effect that owns a + * teardown ORDER — the agent factory's lifecycle chain — must yield THIS + * function so Cordis nests the unregistration at that yield position; + * yielding a wrapper would leave it disposing as a concurrent sibling on + * owner unload, unregistering the agent (and emitting `agent/disposed`) + * while its final turn is still draining. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent) + yield this.enter(agent, this.ctx.agent) this.announce(agent) }.bind(this), 'agents.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity @@ -214,31 +404,48 @@ export class AgentRegistry extends Service { } /** - * Insert an unpublished agent for an ordered factory transaction. + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @returns an idempotent closure that removes this exact entry and emits the - * paired disposal edge; detachment during creation dispatch is deferred. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @returns an idempotent closure that removes this exact entry and emits + * `agent/disposed` with listener failures contained. When called from a + * synchronous `agent/created` listener, removal and disposal wait until + * that creation dispatch unwinds. */ - enter(agent: Agent): () => void { + enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id + if (id !== agent.session.id) { + throw new Error(`agent id "${id}" does not match session id "${agent.session.id}"`) + } const carrier = scopeTarget(agent, agent) - // Prepared transactions arbitrate identity at this publication boundary. - if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) + // This is the authoritative collision boundary. Concurrent create/resume + // operations may both prepare, but only one exact entry can publish. + if (this.store.has(id)) throw new Error(`agent "${id}" is already registered`) const entry: AgentEntry = { id, agent, + owner, carrier, announced: false, announcing: false, detachRequested: false, } this.store.set(id, entry) - this.entries.set(agent, entry) let entered = true const detach = (): void => { if (!entered) return entered = false - // Creation listeners observe one stable entry before paired disposal. + // Every callback reached by this creation dispatch must observe the same + // live entry, and disposal must follow creation. A listener may own + // the advanced detach capability, so make that ordering structural: + // visibility and the paired disposal are deferred until announce()'s + // synchronous dispatch has unwound. if (entry.announcing) { entry.detachRequested = true return @@ -256,7 +463,6 @@ export class AgentRegistry extends Service { /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */ if (this.store.get(entry.id) !== entry) return this.store.delete(entry.id) - this.entries.delete(entry.agent) // An insertion rolled back before announce was never externally created, // so emitting disposed would invent an impossible lifecycle edge. Marking // happens before the created emit: if a later created listener throws, @@ -288,8 +494,8 @@ export class AgentRegistry extends Service { * creation listener). */ announce(agent: Agent): void { - const entry = this.entries.get(agent) - if (entry === undefined || this.store.get(entry.id) !== entry) { + const entry = this.store.get(agent.id) + if (entry === undefined || entry.agent !== agent) { throw new Error(`agent "${agent.id}" is not live in this registry`) } if (entry.announced || entry.announcing) { @@ -318,13 +524,25 @@ export class AgentRegistry extends Service { /** * Look up a live agent. - * @param id - the agent id to look up. + * @param id - the shared agent/session id to look up. * @returns the agent, or undefined when no live agent has that id. */ - get(id: AgentId): Agent | undefined { + get(id: SessionId): Agent | undefined { return this.store.get(id)?.agent } + /** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ + isOwnedBy(id: SessionId, owner: Agent): boolean { + return this.store.get(id)?.owner === owner + } + /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. @@ -332,6 +550,104 @@ export class AgentRegistry extends Service { list(): Agent[] { return [...this.store.values()].map(entry => entry.agent) } + + /** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ + roots(): Agent[] { + return [...this.store.values()] + .filter(entry => entry.owner === undefined) + .map(entry => entry.agent) + } + + /** Reject new initiator boundaries while inherited continuations drain. */ + private closeInitiators(): void { + if (this.initiatorState === 'active') this.initiatorState = 'closing' + } + + /** Wait for returned-Promise boundaries, then invalidate retained references. */ + private disposeInitiators(): Promise { + return (this.initiatorDisposal ??= (async () => { + this.closeInitiators() + this.releaseReentrantInitiatorRuns() + if (this.activeInitiatorRuns !== 0) { + this.initiatorDrain ??= Promise.withResolvers() + await this.initiatorDrain.promise + } + this.initiatorState = 'disposed' + this.initiators.disable() + this.initiatorRuns.disable() + })()) + } + + /** Establish one tracked initiator or clearing boundary. */ + private runWithInitiator(agent: Agent | undefined, operation: () => T): T { + if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE) + const run: InitiatorRun = { + active: true, + parent: this.initiatorRuns.getStore(), + } + this.activeInitiatorRuns += 1 + let result: T + try { + result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation)) + } catch (error: unknown) { + this.releaseInitiatorRun(run) + throw error + } + if (isPromise(result)) { + try { + void Promise.prototype.then.call( + result, + () => { this.releaseInitiatorRun(run) }, + () => { this.releaseInitiatorRun(run) }, + ) + } catch { + // A branded Promise may expose a failing @@species. Observer setup did + // not attach, so preserve the exact return without leaking the run. + this.releaseInitiatorRun(run) + } + } else { + this.releaseInitiatorRun(run) + } + return result + } + + /** Whether one unloading fiber owns this service's lifecycle. */ + private hasLifecycleAncestor(candidate: Fiber): boolean { + let fiber = this.ctx.fiber + while (true) { + if (fiber === candidate) return true + const parent = fiber.parent.fiber + if (parent === fiber) return false + fiber = parent + } + } + + private assertInitiatorsReadable(): void { + if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE) + } + + /** Exclude the boundary chain that initiated this teardown from its own drain. */ + private releaseReentrantInitiatorRuns(): void { + let run = this.initiatorRuns.getStore() + while (run !== undefined) { + this.releaseInitiatorRun(run) + run = run.parent + } + } + + private releaseInitiatorRun(run: InitiatorRun): void { + if (!run.active) return + run.active = false + this.activeInitiatorRuns -= 1 + if (this.activeInitiatorRuns !== 0) return + this.initiatorDrain?.resolve() + this.initiatorDrain = undefined + } } export default AgentRegistry diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 5c709c828b..702861f407 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -5,25 +5,11 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContextEnvelope, JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' - -/** Identifies one live agent in the registry. */ -export type AgentId = Branded<'AgentId'> - -/** - * Brand a string as an {@link AgentId}. - * @param id - the raw agent id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function AgentId(id: string): AgentId { - return id as AgentId -} -import type { ContextEnvelope, JsonValue, Session } from '@deepseek-ai/dsh-session' - declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ @@ -84,6 +70,12 @@ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } +/** Failed-request recovery decision; `retry` opens another numbered step while listeners delegate by calling `next()`. */ +export type RequestErrorDecision = { action: 'fail' } | { action: 'retry' } + +/** Model-request failure with an optional machine-routable provider code. */ +export type RequestError = Error & { code?: string } + /** * The terminal subset of {@link ContinuationDecision}. A listener on * `agent/turn-stop` returns this to make the already-composed continuation @@ -94,9 +86,10 @@ export type ContinuationStop = Extract /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus @@ -198,23 +191,17 @@ declare module 'cordis' { // ---- step/request extension seams (serial + waterfall) ---- /** - * Awaited serial checkpoint for session-surface mutation after prompt - * assembly and before `step/start`; appends land outside the pending step. - * The loop derives history once afterward, so compaction records and - * replacements are included without rewriting an assembled request. The - * prompt and prefix are the exact pressure inputs for that request, and + * Awaited serial checkpoint before `step/start`; appends land outside the + * pending step and are included when the loop derives request history. * `signal` cancels listener work. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent opening the step. * @param turn - the open turn number. * @param step - the pending step number. - * @param fullSystemPrompt - the assembled prompt. - * @param sessionPrefix - the frozen request prefix. * @param signal - the turn abort signal. * @mode serial */ - // TODO: Move prompt-pressure inputs behind a compaction-specific seam if no second consumer appears. - 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void + 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void /** * Allow, rewrite, or block one drained prompt before it becomes a user * message. Call `next()` for the unchanged default. @@ -242,9 +229,9 @@ declare module 'cordis' { * result is computed once per loop instance, logged on its anchoring request * header, and reused so the provider prefix remains stable. Interrupted * composition is discarded. Composition precedes the first `agent/pre-step` - * and request boundary, so listener appends join the current request and - * pressure accounting sees the composed prefix. Changing context belongs in - * history; contributors should prepend to `await next()` to preserve registration order. + * and request boundary, so listener appends join the current request. + * Changing context belongs in history; contributors should prepend to + * `await next()` to preserve registration order. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param agent - the agent whose session prefix is being composed. * @param prefix - the frozen seed; return an extended replacement. @@ -263,6 +250,32 @@ declare module 'cordis' { * @mode waterfall */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise + /** + * Awaited serial checkpoint after the response, real or synthetic tool + * results, injected context, and steering are durable but before `step/end`. + * A cancelled tool batch reaches this checkpoint with an aborted signal. + * @param agent - the agent whose step is settling. + * @param turn - the open turn number. + * @param step - the open step number. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ + 'agent/post-step'(this: Scoped, agent: Agent, turn: number, step: number, signal: AbortSignal): Promise | void + /** + * Recover a model-request failure after its failed step has closed. `retry` + * opens a new numbered step; `fail` preserves the original request error. + * Call `next()` to delegate to the next recovery listener or the default. + * @param agent - the agent whose request failed. + * @param turn - the open turn number. + * @param step - the failed step number. + * @param error - the original model-request failure. + * @param retryAttempt - zero-based number of prior recovery retries. + * @param signal - the turn abort signal. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ + 'agent/request-error'(this: Scoped, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise): Promise /** * Override whether the turn continues. The default continues after tool * calls or steering and stops otherwise; a continue reason becomes steering. diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts new file mode 100644 index 0000000000..7e0b70d13c --- /dev/null +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { runInNewContext } from 'node:vm' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' + +function agent(id: string): Agent { + return { id: SessionId(id) } as Agent +} + +async function harness(): Promise<{ + ctx: Context + service: AgentRegistry + dispose: () => Promise +}> { + const ctx = new Context() + const fiber = await ctx.plugin(AgentRegistry) + return { + ctx, + service: ctx.agents, + dispose: fiber.dispose, + } +} + +/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */ +async function promptly(task: Promise): Promise { + const timeout = Promise.withResolvers() + const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000) + try { + return await Promise.race([task, timeout.promise]) + } finally { + clearTimeout(timer) + } +} + +describe('AgentRegistry initiator scope', () => { + it('reports an absent initiator and requires an active boundary', async () => { + const { service, dispose } = await harness() + expect(service.currentInitiator()).toBeUndefined() + expect(() => service.requireInitiator()).toThrow('no initiating agent is active') + await dispose() + }) + + it('preserves exact synchronous and Promise return identities across await', async () => { + const { service, dispose } = await harness() + const initiator = agent('identity') + const value = { result: true } + expect(service.withInitiator(initiator, () => { + expect(service.requireInitiator()).toBe(initiator) + return value + })).toBe(value) + + const promise = service.withInitiator(initiator, async () => { + expect(service.requireInitiator()).toBe(initiator) + await Promise.resolve() + expect(service.requireInitiator()).toBe(initiator) + return value + }) + expect(service.withInitiator(initiator, () => promise)).toBe(promise) + await expect(promise).resolves.toBe(value) + expect(service.currentInitiator()).toBeUndefined() + await dispose() + }) + + it('tracks a branded Promise without calling its overridable then property', async () => { + const { service, dispose } = await harness() + const initiator = agent('overridden-then') + const release = Promise.withResolvers() + void Object.defineProperty(release.promise, 'then', { + value: () => { throw new Error('overridden then called') }, + }) + + const pending = service.withInitiator(initiator, () => release.promise) + expect(pending).toBe(release.promise) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + release.resolve(true) + await new Promise((resolve, reject) => { + void Promise.prototype.then.call(pending, resolve, reject) + }) + await disposal + expect(disposed).toBe(true) + }) + + it('preserves a settled branded Promise when its species blocks observer construction', async () => { + const { service, dispose } = await harness() + const initiator = agent('invalid-species') + const promise = Promise.resolve() + const constructor = {} + Object.defineProperty(constructor, Symbol.species, { + get: () => { throw new Error('invalid species') }, + }) + void Object.defineProperty(promise, 'constructor', { value: constructor }) + + expect(service.withInitiator(initiator, () => promise)).toBe(promise) + await dispose() + }) + + it('isolates overlapping initiators', async () => { + const { service, dispose } = await harness() + const a = agent('a') + const b = agent('b') + const bothStarted = Promise.withResolvers() + const release = Promise.withResolvers() + let starts = 0 + const run = (initiator: Agent): Promise => service.withInitiator(initiator, async () => { + expect(service.requireInitiator()).toBe(initiator) + starts += 1 + if (starts === 2) bothStarted.resolve(true) + await release.promise + expect(service.requireInitiator()).toBe(initiator) + }) + + const pending = [run(a), run(b)] + await bothStarted.promise + expect(service.currentInitiator()).toBeUndefined() + release.resolve(true) + await Promise.all(pending) + await dispose() + }) + + it('restores nested and explicitly cleared boundaries', async () => { + const { service, dispose } = await harness() + const parent = agent('parent') + const child = agent('child') + + service.withInitiator(parent, () => { + expect(service.requireInitiator()).toBe(parent) + service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) }) + expect(service.requireInitiator()).toBe(parent) + service.withoutInitiator(() => { + expect(service.currentInitiator()).toBeUndefined() + expect(() => service.requireInitiator()).toThrow('no initiating agent is active') + }) + expect(service.requireInitiator()).toBe(parent) + }) + expect(service.currentInitiator()).toBeUndefined() + await dispose() + }) + + it('restores the parent after synchronous throws and rejected operations', async () => { + const { service, dispose } = await harness() + const parent = agent('parent') + const child = agent('child') + const syncError = new Error('sync failure') + const asyncError = new Error('async failure') + + service.withInitiator(parent, () => { + expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError) + expect(service.requireInitiator()).toBe(parent) + }) + await expect(service.withInitiator(child, async () => { + await Promise.resolve() + throw asyncError + })).rejects.toBe(asyncError) + expect(service.currentInitiator()).toBeUndefined() + await dispose() + }) + + it('stops new boundaries, drains active Promises, and invalidates retained references', async () => { + const { ctx, service, dispose } = await harness() + const initiator = agent('draining') + const release = Promise.withResolvers() + const pending = service.withInitiator(initiator, async () => { + await release.promise + expect(service.requireInitiator()).toBe(initiator) + }) + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + + expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed') + expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed') + expect(disposed).toBe(false) + expect(ctx.get('agents')).toBeUndefined() + release.resolve(true) + await pending + await disposal + expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') + expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed') + }) + + it('drains cross-realm Promise boundaries before disposal', async () => { + const { service, dispose } = await harness() + const initiator = agent('cross-realm') + const release = Promise.withResolvers() + const operation = runInNewContext( + '(async () => { await release; inspect() })', + { + release: release.promise, + inspect: () => { expect(service.requireInitiator()).toBe(initiator) }, + }, + ) as () => Promise + const pending = service.withInitiator(initiator, operation) + expect(pending).not.toBeInstanceOf(Promise) + + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + release.resolve(true) + await pending + await disposal + expect(disposed).toBe(true) + }) + + it('does not self-deadlock when a boundary returns service disposal', async () => { + const { service, dispose } = await harness() + const initiator = agent('service-disposer') + + const returned = service.withInitiator(initiator, dispose) + await promptly(returned) + + expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') + }) + + it('does not self-deadlock when nested boundaries return ancestor disposal', async () => { + const { ctx, service } = await harness() + const parent = agent('parent-disposer') + const child = agent('child-disposer') + let disposal: Promise | undefined + + const returned = service.withInitiator(parent, () => service.withInitiator(child, () => { + disposal = ctx.fiber.dispose() + return disposal + })) + expect(returned).toBe(disposal) + + await promptly(returned) + expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') + }) + + it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => { + const { ctx, service } = await harness() + const initiator = agent('async-disposer') + const unrelated = agent('unrelated') + const release = Promise.withResolvers() + const pending = service.withInitiator(unrelated, async () => { + await release.promise + expect(service.requireInitiator()).toBe(unrelated) + }) + + const returned = service.withInitiator(initiator, async () => { + await Promise.resolve() + await ctx.fiber.dispose() + }) + let disposed = false + void returned.then(() => { disposed = true }) + await Promise.resolve() + await Promise.resolve() + expect(disposed).toBe(false) + + release.resolve(true) + await pending + await promptly(returned) + + expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed') + }) +}) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..5d79d1a91f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,15 +2,16 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' + import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { - const id = AgentId(rawId) + const id = SessionId(rawId) return { id, options: {}, - session: new Session(SessionId(`${id}-session`)), + session: new Session(id), status: 'idle', ctx: new Context(), send() {}, @@ -41,6 +42,7 @@ describe('AgentRegistry', () => { const dispose = ctx.agents.register(agent) expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) + expect(ctx.agents.roots()).toEqual([agent]) expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) dispose() @@ -48,6 +50,37 @@ describe('AgentRegistry', () => { expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) + it('rejects an agent whose registry and session identities differ', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + + expect(() => ctx.agents.enter(agent, undefined)) + .toThrow('agent id "agent-id" does not match session id "session-id"') + expect(ctx.agents.list()).toEqual([]) + }) + + it('tracks runtime creator ownership separately from registry order', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const root = stubAgent('root') + const child = stubAgent('child') + const detachRoot = ctx.agents.enter(root, undefined) + ctx.agents.announce(root) + const detachChild = ctx.agents.enter(child, root) + ctx.agents.announce(child) + + expect(ctx.agents.list()).toEqual([root, child]) + expect(ctx.agents.roots()).toEqual([root]) + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true) + expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false) + expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false) + + detachChild() + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false) + detachRoot() + }) + it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -57,7 +90,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') - expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) @@ -93,7 +126,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') - const detachFirst = ctx.agents.enter(first) + const detachFirst = ctx.agents.enter(first, undefined) expect(lifecycle).toEqual([]) ctx.agents.announce(first) expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) @@ -101,7 +134,7 @@ describe('AgentRegistry', () => { detachFirst() const replacement = stubAgent('split') - const detachReplacement = ctx.agents.enter(replacement) + const detachReplacement = ctx.agents.enter(replacement, undefined) detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) @@ -121,7 +154,7 @@ describe('AgentRegistry', () => { }) ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) ctx.on('agent/disposed', () => void order.push('disposed')) - const detach = ctx.agents.enter(agent) + const detach = ctx.agents.enter(agent, undefined) ctx.agents.announce(agent) expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() @@ -158,11 +191,11 @@ describe('AgentRegistry factory seam', () => { const factory: AgentFactory = { async createAgent(ownerCtx, options) { calls.create.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, async resume(ownerCtx, options) { calls.resume.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } return { factory, calls } @@ -171,15 +204,15 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) let callerFiber: Context['fiber'] | undefined await ctx.plugin(Object.assign(async (inner: Context) => { callerFiber = inner.fiber - await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await inner.agents.create({ sessionId: SessionId('create-s') }) + await inner.agents.resume({ resumeSessionId: SessionId('resume-s') }) }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) @@ -192,9 +225,9 @@ describe('AgentRegistry factory seam', () => { inner.agents.setFactory(stubFactory().factory) expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) }, { inject: ['agents'] })) - await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined() + await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined() await owner.dispose() - await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) }) it('canonicalizes an already traced Service before tracing it for the caller', async () => { @@ -214,18 +247,18 @@ describe('AgentRegistry factory seam', () => { } async createAgent(_ownerCtx: Context, options: CreateAgentOptions) { this.calls().push('create') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } } async resume(_ownerCtx: Context, options: ResumeAgentOptions) { this.calls().push('resume') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } } } await ctx.plugin(TracedFactory) const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory ctx.agents.setFactory(traced) - await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await ctx.agents.create({ sessionId: SessionId('create-s') }) + await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') }) const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] expect(states.get(raw!)).toEqual(['create', 'resume']) }) diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 856ef899f7..23c558bd95 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -1,12 +1,13 @@ /** - * Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`). + * Contract and negative-path tests for the cordis catalog generator + * (`scripts/gen-cordis-catalog.ts`). */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ @@ -58,6 +59,8 @@ describe('gen-cordis-catalog collectEvents', () => { )) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) + expect(events[0]?.jsDoc).toBe('/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */') + expect(renderEvents(events)).toContain("```ts cordis-catalog\n/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n'fix/happened'(id: string): void\n```") }) it('classifies a trailing-next signature as a waterfall', () => { @@ -74,6 +77,33 @@ describe('gen-cordis-catalog collectEvents', () => { expect(events[0]?.mode).toBe('parallel') }) + it('accepts linked, foundation, generic-parameter, and explicitly exempt signature types', () => { + const events = collectEvents(make( + ' /**\n * Carry linked and foundation types.\n * @param value - the linked value.\n * @param preset - deployment metadata outside the core catalog.\n * @param signal - cancellation.\n * @mode parallel\n */\n \'fix/typed\'(value: Readonly, preset: PresetSpec, signal: AbortSignal): Promise', + )) + expect(events).toHaveLength(1) + expect(renderEvents(events)).toContain('Types: [SessionEvent](../core-data-structures/core.md)') + expect(renderEvents(events)).not.toContain('[PresetSpec]') + }) + + it('aggregates every unclassified signature type with its source and remediation', () => { + const expected = new RegExp([ + '2 signature type-link coverage violation\\(s\\)', + 'fix/one', + 'packages/group/fix/src/index.ts', + 'MissingOne', + 'fix/two', + 'packages/group/fix/src/index.ts', + 'missingTwo', + 'Add it to LINK_MAP', + 'FOUNDATION_TYPE_NAMES', + 'TYPE_LINK_EXEMPTIONS', + ].join('[\\s\\S]*')) + expect(() => collectEvents(make( + ' /**\n * First.\n * @param value - first value.\n * @mode emit\n */\n \'fix/one\'(value: MissingOne): void\n /**\n * Second.\n * @param value - second value.\n * @mode emit\n */\n \'fix/two\'(value: missingTwo): void', + ))).toThrow(expected) + }) + it('hard-errors when an event is missing its @mode tag', () => { expect(() => collectEvents(make( ' /** No mode here. */\n \'fix/untagged\'(): void', @@ -158,6 +188,17 @@ export class FixService { expect(services).toHaveLength(1) expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' }) expect(services[0]?.methods).toHaveLength(3) + expect(services[0]?.methods[0]).toEqual({ + signature: 'run(id: string): string', + jsDoc: '/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */', + }) + expect(renderServices(services)).toContain('```ts cordis-catalog\n/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */\nrun(id: string): string\n\n/** Fire and forget (void needs no @returns). */\npoke(): void') + }) + + it('hard-errors on an unclassified service-method signature type', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Use an unknown value.\n * @param value - the value.\n */\n run(value: MissingServiceType): void {}\n}', + ))).toThrow(/service method ctx\.fix\.run .* references unclassified type 'MissingServiceType'/) }) it('hard-errors on a public method with no JSDoc at all', () => { diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 46b6ecb703..07bbc4c078 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback. - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. -- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. +- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -48,6 +48,7 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. +- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`. - `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log. @@ -84,21 +85,45 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Derived message history -**What the model sees**: The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. +#### What the model sees -**Token effect**: Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records. +The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. + +#### Token effect + +Appended surface entries are resent on later steps. A `replace` surface operation removes the shadowed entries from future inputs without deleting their raw log records. + +#### KV Cache effect + +Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only. ### Crash-repair result -**What the model sees**: If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.` +#### What the model sees -**Token effect**: Zero tokens in an intact session. Each repaired call adds this retained error text on resume. +If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.` + +#### Token effect + +Zero tokens in an intact session. Each repaired call adds this retained error text on resume. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Logged request header -**What the model sees**: The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`. +#### What the model sees -**Token effect**: Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost. +The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`. + +#### Token effect + +Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost. + +#### KV Cache effect + +Logging causes no invalidation, and exact reconstruction preserves request-prefix identity. A later header with changed prefix, prompt, or schemas may invalidate reuse from its first difference. ## Known Limitations and Deferred Work diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 985d475101..b8b76ec2ed 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,13 +16,14 @@ import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' +import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' -export type { SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' +export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -251,22 +252,12 @@ export function renderContextContent( */ export class Session { private log: SessionEvent[] = [] - /** Incremental acceptance state, kept separate from the public lazy view. */ - private readonly surfaceValidator = new SurfaceManager(this.log) - - /** - * Derived surface — a cached order of message-producing event sequences. - * Lazily rebuilt from `surfaceOp` markers in the log; processes only new - * events (delta) on each access — the log is append-only, so prior events - * never change. - * Undefined until first accessed (including after fork/seed). - */ - private _surface: SurfaceManager | undefined + /** Single incremental owner of surface acceptance and projection state. */ + private readonly surfaceManager = new SurfaceManager(this.log) /** The ordered surface over this session's event log. */ - get surface(): SurfaceManager { - if (!this._surface) this._surface = new SurfaceManager(this.log) - return this._surface + get surface(): SessionSurface { + return this.surfaceManager } /** @@ -279,7 +270,12 @@ export class Session { */ readonly header: SessionHeader - constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId { + return this.header.id + } + + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a @@ -304,7 +300,7 @@ export class Session { // live append and a full-log fold. The candidate is planned before it // enters `log`, so a failure cannot partially mutate the surface. try { - this.surfaceValidator.validateNext(snapshot) + this.surfaceManager.validateNext(snapshot) } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } @@ -397,7 +393,7 @@ export class Session { data: dataSnapshot, ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), } as unknown as SessionEvent) - this.surfaceValidator.validateNext(event as SessionEvent) + this.surfaceManager.validateNext(event as SessionEvent) if (entry !== undefined) entry.appending = true try { @@ -463,7 +459,7 @@ export class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -471,8 +467,9 @@ export class Session { * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[] { - const nodes = this.surface.nodes - const generation = this.surface.replaceGeneration + const surface = this.surface + const nodes = surface.nodes + const generation = surface.replaceGeneration if (generation !== this.derivedGeneration) { this.derived = [] this.derivedNodes = 0 diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index d4f43bc303..cf03ae685d 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -55,6 +55,14 @@ export interface SurfaceFoldResult { replacements: SurfaceFoldReplacement[] } +/** Readonly live projection of the message-producing session events. */ +export interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number +} + /** Mutable state shared by complete and incremental folds. */ interface SurfaceFoldState { nodes: number[] @@ -244,7 +252,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult } /** Incremental ordered surface view and append-boundary validator. */ -export class SurfaceManager { +export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() /** Last processed seq; -1 folds a seeded log on first access. */ diff --git a/packages/core/session/tests/gen-persistence-catalog.spec.ts b/packages/core/session/tests/gen-persistence-catalog.spec.ts index cca9cbfa61..9f18e75bbe 100644 --- a/packages/core/session/tests/gen-persistence-catalog.spec.ts +++ b/packages/core/session/tests/gen-persistence-catalog.spec.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { annotateSurface, + collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes, render, @@ -56,6 +57,7 @@ describe('gen-persistence-catalog collectLogEvents', () => { scope: 'fix', doc: 'A thing was recorded.', payload: '{ turn: number }', + declaration: '/** A thing was recorded. */\n\'fix/happened\': { turn: number }', source: 'packages/core/fix/src/types.ts:3', }) }) @@ -102,10 +104,13 @@ describe('gen-persistence-catalog collectLogEvents', () => { it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => { const events = collectLogEvents(make({ 'packages/group/fix/src/types.ts': merge( - ' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }', + ' /** Wide payload. */\n \'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }', ), })) expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }') + expect(events[0]?.declaration).toBe( + '/** Wide payload. */\n\'fix/wide\': {\n /** Alpha values. */\n alpha: string[]\n range: { start: number; end: number }\n count: number\n}', + ) }) it('hard-errors on a member with no description prose', () => { @@ -158,6 +163,59 @@ describe('gen-persistence-catalog collectLogEvents', () => { }) }) +describe('gen-persistence-catalog collectEventEnvelopeTypes', () => { + const declarations = `/** Event keys. */ +export type SessionEventType = keyof SessionEventMap +/** Surface-producing event keys. */ +export type SurfaceEventType = 'fix/message' +/** Surface placement. */ +export type SurfaceOp = 'append' +/** One persisted event. */ +export type SessionEvent = { type: T } +` + + it('extracts the envelope declarations with their complete JSDoc in canonical order', () => { + const entries = collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations, + })) + expect(entries.map(entry => entry.name)).toEqual([ + 'SessionEventType', + 'SurfaceEventType', + 'SurfaceOp', + 'SessionEvent', + ]) + expect(entries[3]).toMatchObject({ + declaration: '/** One persisted event. */\nexport type SessionEvent = { type: T }', + source: 'packages/core/fix/src/types.ts:8', + }) + }) + + it('hard-errors when an envelope declaration is missing', () => { + expect(() => collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations.replace('/** Surface placement. */\nexport type SurfaceOp = \'append\'\n', ''), + }))).toThrow(/missing event-envelope declaration\(s\): SurfaceOp/) + }) + + it('hard-errors on duplicate, unexported, undocumented, or mistagged envelope declarations', () => { + const violations = new RegExp([ + '4 JSDoc completeness violation\\(s\\)', + '[\\s\\S]*not exported', + '[\\s\\S]*@mode tag', + '[\\s\\S]*SurfaceOp.*no description prose', + '[\\s\\S]*SessionEvent.*already declared', + ].join('')) + expect(() => collectEventEnvelopeTypes(make({ + 'packages/core/fix/package.json': OWNER_MANIFEST, + 'packages/core/fix/src/types.ts': declarations + .replace('/** Event keys. */\nexport type SessionEventType', '/** Event keys.\n * @mode emit\n */\ntype SessionEventType') + .replace('/** Surface placement. */\n', '') + + '/** Duplicate event. */\nexport type SessionEvent = { type: never }\n', + }))).toThrow(violations) + }) +}) + describe('gen-persistence-catalog collectSurfaceEventTypes', () => { it('parses the literal union', () => { const types = collectSurfaceEventTypes(make({ @@ -192,9 +250,21 @@ describe('gen-persistence-catalog annotateSurface + render', () => { scope: name.split('/')[0] ?? name, payload: '{ turn: number }', doc: `Records ${name}.`, + declaration: `/** Records ${name}. */\n'${name}': { turn: number }`, source: 'packages/core/fix/src/types.ts:3', }) + const envelopeTypes = [ + 'SessionEventType', + 'SurfaceEventType', + 'SurfaceOp', + 'SessionEvent', + ].map(name => ({ + name: name as 'SessionEventType' | 'SurfaceEventType' | 'SurfaceOp' | 'SessionEvent', + declaration: `/** ${name}. */\nexport type ${name} = never`, + source: 'packages/core/fix/src/types.ts:1', + })) + it('badges union members surface and everything else log-only', () => { const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']) expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]]) @@ -205,11 +275,13 @@ describe('gen-persistence-catalog annotateSurface + render', () => { .toThrow(/'fix\/ghost' name no declared log event/) }) - it('renders badges, payload fences, and the generated-file header', () => { - const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])) + it('renders badges, declaration fences, and the generated-file header', () => { + const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']), envelopeTypes) expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts') + expect(out).toContain('# Session Persistence Event Catalog') + expect(out).toContain('```ts persistence-catalog\n/** SessionEventType. */\nexport type SessionEventType = never') expect(out).toContain('#### `fix/message` — surface') expect(out).toContain('#### `fix/marker` — log-only') - expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```') + expect(out).toContain('```ts persistence-catalog\n/** Records fix/marker. */\n\'fix/marker\': { turn: number }\n```') }) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 16a74283be..3b15157bde 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,10 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session' +import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { + it('exposes one stable readonly surface view', () => { + const session = new Session(SessionId('surface-view')) + const surface = session.surface + + expectTypeOf(surface).toEqualTypeOf() + expect(surface).toBe(session.surface) + }) + it('derives message history from the event log', () => { const session = new Session(SessionId('s1')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -1031,6 +1039,45 @@ describe('SessionStore', () => { expect(observed).toEqual([appended]) }) + it('does not publish a surface transition rejected by internal dispatch', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) + session.append('user/message', { + content: [{ type: 'text', text: 'source' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const surface = session.surface + let reject = true + ctx.on('internal/dispatch', (_mode, name) => { + if (name === 'session/event' && reject) { + reject = false + throw new Error('reject surface candidate') + } + }) + + expect(() => session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 1, + content: [{ type: 'text', text: 'replacement' }], + }, { + surfaceOp: { op: 'replace', start: 0, end: 0 }, + sourceEventSeqs: [0], + })).toThrow('reject surface candidate') + + expect(session.events).toHaveLength(1) + expect(surface.nodes).toEqual([0]) + expect(surface.replaceGeneration).toBe(0) + + session.append('user/message', { + content: [{ type: 'text', text: 'next' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expect(surface.nodes).toEqual([0, 1]) + expect(surface.replaceGeneration).toBe(0) + }) + it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fe222f8f2f..d239533476 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -142,6 +142,11 @@ describe('SurfaceManager', () => { it('leaves incremental state unchanged when candidate validation fails', () => { const s = new Session(SessionId('atomic-validation')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const surface = s.surface + const nodes = surface.nodes + + expect(nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.replaceGeneration).toBe(0) expect(() => s.append( 'assistant/message', @@ -150,8 +155,16 @@ describe('SurfaceManager', () => { )).toThrow(/missing 0/) expect(s.events).toHaveLength(1) + expect(s.surface).toBe(surface) + expect(surface.nodes).toEqual([0]) + expect(surface.replaceGeneration).toBe(0) + expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(s.surface.nodes).toEqual([0, 1]) + expect(surface.nodes).toBe(nodes) + expect(surface.nodes).toEqual([0, 1]) + expect(surface.replaceGeneration).toBe(0) + expect(surface.nodes).toEqual(foldSurface(s.events).nodes) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index e8975bf17e..91b9eaf164 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -44,21 +44,37 @@ Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/archi ### System prompt -**What the model sees**: Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +#### What the model sees -**Token effect**: Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. +Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. -#### Harness identity +##### Harness identity ```markdown You are an AI agent powered by the DeepSeek Harness SDK. ``` +#### Token effect + +Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. + +#### KV Cache effect + +Prefix-stable while identity, persona, variables, section text, and order render identically. Any change may invalidate reuse from the first changed system-prompt token. + ### Tool schemas -**What the model sees**: For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#tool-package-map), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance. +#### What the model sees -**Token effect**: Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content. +For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#tool-package-map), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance. + +#### Token effect + +Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content. + +#### KV Cache effect + +Prefix-stable while the visible schema set, rendering, and order are unchanged. Registration, restriction, or reordering may invalidate reuse from the first changed schema token. ## Known Limitations and Deferred Work diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 16fba41e54..1acb633a59 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -21,6 +21,7 @@ tools: - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. - `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. +- `ctx.tools.executionMode(exec)` returns `parallel` only when the visible definition's `isConcurrencySafe(exec.arguments)` classifier returns exactly `true`; unknown, hidden, undeclared, invalid, or throwing classifications are exclusive. ### Injected services @@ -32,7 +33,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Key types -- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, and cooperative `timeoutMs`. +- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, cooperative `timeoutMs`, and optional per-call `isConcurrencySafe(args)` classification. - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. @@ -87,6 +88,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. +Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract. + ### Structured-output schema subset `StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. @@ -108,21 +111,33 @@ Under `code` or `both`, the registry exposes the reserved `run_code` transport a - **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/envelope/meta even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. +### Parallel execution + +The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale. + ## Model Experience ### Normal tool schemas -**What the model sees**: In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. +#### What the model sees -**Token effect**: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent. +In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. + +#### Token effect + +Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent. + +#### KV Cache effect + +Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token. ### Code Mode schema and system prompt -**What the model sees**: Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface. +#### What the model sees -**Token effect**: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. +Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface. -#### Code Mode SDK instructions +##### Code Mode SDK instructions ```markdown ## Writing code for run_code @@ -137,17 +152,33 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only The available tools: ``` +#### Token effect + +Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. + +#### KV Cache effect + +Prefix-stable while the Code Mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. + ### Tool-call history and results -**What the model sees**: The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result. +#### What the model sees -**Token effect**: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them. +The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result. + +#### Token effect + +Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work -- **Native tool calls execute sequentially** — `ToolDefinition` carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (`TODO(review)`). +- **Concurrency policy is not an event seam** — `executionMode()` reads the resolved tool definition directly; plugins can only declare a classifier on definitions they own. - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). -- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input. +- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. - **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 28ecada37f..5fc045ace2 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -117,9 +117,6 @@ declare module 'cordis' { } } -// TODO(concurrency): revisit these shapes when concurrency metadata becomes useful -// (for example, a read-only hint that would permit safe parallel execution). - /** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } @@ -134,6 +131,20 @@ export interface ToolDefinition extends ToolSchema { * cooperative implementation that can reach quiescence when the signal aborts. */ timeoutMs?: number + /** + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. + * + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the + * [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. + */ + isConcurrencySafe?(args: unknown): boolean /** * Optional: how to present the PENDING state of one call in a UI, derived from * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows @@ -195,6 +206,14 @@ export interface ToolExecutionInput { signal?: AbortSignal } +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ +export type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } + /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; @@ -222,6 +241,47 @@ export interface ToolRunContext extends ToolExecution { deferContext(context: HookContext): void } +/** + * Scheduler-only result after ordered pre-execute and guards. A `post-result` + * still receives post-execute; a `final-result` bypasses it. + * @internal + */ +export type ScheduledToolPreparation = + | { kind: 'dispatch'; exec: ToolRunContext } + | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult } + | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } + +/** + * Scheduler-only dispatch result. A `post-result` still receives post-execute; + * a `final-result` already matches {@link ToolRegistry.execute} failure semantics. + * @internal + */ +export type ScheduledToolDispatch = + | { kind: 'post-result'; result: ToolExecutionResult } + | { kind: 'final-result'; result: ToolExecutionResult } + +/** + * Symbol-keyed scheduler view that keeps pre/post policy ordered while + * overlapping dispatch. Ordinary callers use {@link ToolRegistry.execute}; + * this is not a plugin seam. + * @internal + */ +export interface ToolRegistryScheduler { + /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */ + prepare(exec: ToolExecutionInput): Promise + /** Run only the around-dispatch/body stage. */ + dispatch(exec: ToolRunContext): Promise + /** Run ordered post-execute finalization, then materialize and notify the final outcome. */ + finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise + /** Materialize and notify a final outcome that must bypass post-execute. */ + finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult +} + +/** + * Scheduler entry point omitted from the generated named service API. + * @internal + */ +export const TOOL_REGISTRY_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') /** Structured error metadata for a failed tool call (alongside the model-facing text). */ export interface ToolErrorInfo { name: string @@ -382,6 +442,16 @@ export class ToolRegistry extends Service { mode: z.union(['native', 'code', 'both'] as const).default('native'), }) + /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */ + readonly [TOOL_REGISTRY_SCHEDULER]: ToolRegistryScheduler = { + prepare: exec => this.prepareScheduledExecution(exec), + dispatch: exec => this.dispatchScheduledExecution(exec), + finalize: (exec, result) => this.finalizeScheduledExecution(exec, result), + finish: (exec, result) => this.finishScheduledExecution(exec, result), + } + + /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ + private deferredContexts = new WeakMap() private global = new Map() private scoped = new Map>() /** Compiled restriction filters, per scope (see {@link restrict}). */ @@ -682,6 +752,24 @@ export class ToolRegistry extends Service { } } + /** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ + executionMode(exec: ToolExecutionInput): ToolExecutionMode { + const tool = this.get(exec.name, exec.agent) + if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } + try { + const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) + return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } + } catch { + return { kind: 'exclusive' } + } + } + /** * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error @@ -692,6 +780,28 @@ export class ToolRegistry extends Service { * @returns the materialized final result. */ async execute(exec: ToolExecutionInput): Promise { + return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared)) + } + + private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise { + switch (prepared.kind) { + case 'dispatch': { + const dispatched = await this.dispatchScheduledExecution(prepared.exec) + return dispatched.kind === 'post-result' + ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result) + : this.finishScheduledExecution(prepared.exec, dispatched.result) + } + case 'post-result': + return await this.finalizeScheduledExecution(prepared.exec, prepared.result) + case 'final-result': + return this.finishScheduledExecution(prepared.exec, prepared.result) + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + return assertNever(prepared, 'scheduled tool preparation') + } + } + + private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: ToolRunContext } { const deferredContexts: HookContext[] = [] const token = createExecutionToken() const callId = exec.callId @@ -710,105 +820,143 @@ export class ToolRegistry extends Service { deferredContexts.push(context) }, } - let execution: ToolRunContext try { const detached = snapshotJsonValue(exec.arguments) if (detached === undefined) { throw new TypeError('tool execution arguments must be losslessly JSON-serializable') } - execution = { - ...base, - arguments: deepFreeze(detached), - } + const execution: ToolRunContext = { ...base, arguments: deepFreeze(detached) } + this.deferredContexts.set(execution, deferredContexts) + return { kind: 'ready', exec: execution } } catch (error: unknown) { - execution = { ...base, arguments: undefined } - const result = this.materializeFinalResult(toolErrorResult(error)) - this.notifyResult(execution, result) - return result + const execution: ToolRunContext = { ...base, arguments: undefined } + return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } } - let result: ToolExecutionResult + } + + /** + * Run the ordered pre-execute and monotonic guard stages for the scheduler. + * @param input - the caller-supplied execution input. + * @returns the prepared execution plus the next scheduler stage. + * @internal + */ + private async prepareScheduledExecution(input: ToolExecutionInput): Promise { + return this.prepareExecution(input, prepared => prepared) + } + + private async prepareExecution( + input: ToolExecutionInput, + next: (prepared: ScheduledToolPreparation) => T | PromiseLike, + ): Promise { + const created = this.createExecution(input) + if (created.kind !== 'ready') return next(created) + const exec = created.exec try { - result = this.materializeFinalResult(await this.executePipeline(execution, deferredContexts)) + const carrier = scopeTarget(this, exec.agent) + const gate = await this.ctx.waterfall( + carrier, 'tools/pre-execute', exec, + () => Promise.resolve({ kind: 'allow' }), + ) + const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate + const denialReason = decision.kind === 'allow' + ? this.guardReason(exec) + : decision.reason + if (denialReason !== undefined) { + return await next({ + kind: 'post-result', + exec, + result: { + content: [{ type: 'text', text: `Error: ${denialReason}` }], + isError: true, + }, + }) + } + return await next({ kind: 'dispatch', exec }) } catch (error: unknown) { - // Outer backstop: a throwing pre/post-execute listener, guard, or the - // waterfall machinery becomes an isError result, never a turn failure. - result = this.materializeFinalResult(toolErrorResult(error)) + return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) } - this.notifyResult(execution, result) - return result } - /** Run the transformable pipeline; {@link execute} owns final normalization and notification. */ - private async executePipeline(exec: ToolRunContext, deferredContexts: HookContext[]): Promise { - // --- Gate: tools/pre-execute. An `ask` resolves through the optional - // approval seam (or degrades to deny) before the monotonic guards run. The - // carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only - // its own agent's calls (agent-less calls are subject-less). - const carrier = scopeTarget(this, exec.agent) - const gate = await this.ctx.waterfall( - carrier, 'tools/pre-execute', exec, - () => Promise.resolve({ kind: 'allow' }), - ) - const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate - const denialReason = decision.kind === 'allow' - ? this.guardReason(exec) - : decision.reason - if (denialReason !== undefined) { - // Every non-grant, including a failed/unavailable approval request, takes - // the same deny path and still reaches post-policy plus result observers. - const denied: ToolExecutionResult = { - content: [{ type: 'text', text: `Error: ${denialReason}` }], - isError: true, - } - return await this.postExecute(exec, denied) - } - - // --- Around-dispatch: tools/execute. The base `next` is the dispatch- - // with-normalization thunk — the tool body's own try/catch turns a throw - // into an isError result so a wrapper (and post-execute) can inspect it; - // an unknown tool routes through the same catch. A `tools/execute` listener - // (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal` - // before delegating and inspect the normalized result after. Dispatched with the - // same carrier as the gate, so an `agent.ctx` wrapper wraps only its own - // agent's calls. --- - const result = await this.ctx.waterfall( - carrier, 'tools/execute', exec, - async (): Promise => { - try { - // Resolve through the CALLER's visible view ({@link get}): a scoped - // tool shadows its global name-twin for that agent, and a - // restricted-away global tool is exactly as absent as a nonexistent - // one — same UNKNOWN_TOOL result, no capability leak in the error. - const tool = this.get(exec.name, exec.agent) - if (!tool) throw new ToolNotFoundError(exec.name) - // Normalize the two `execute` return shapes: a bare ContentBlock[] (no - // meta) or a { content, meta } object (a tool attaching a private - // presentation payload). An array IS the content; the object carries it. - const returned = await tool.execute(exec.arguments, exec) - const content = Array.isArray(returned) ? returned : returned.content - const meta = Array.isArray(returned) ? undefined : returned.meta - return { content, isError: false, ...meta !== undefined ? { meta } : {} } - } catch (error: unknown) { - return toolErrorResult(error) + /** + * Run around-dispatch and the tool body. Tool and unknown-tool failures still + * receive post-execute; pipeline failures are already final. + * @param exec - the prepared execution. + * @returns whether the result still needs post-execute. + * @internal + */ + private async dispatchScheduledExecution(exec: ToolRunContext): Promise { + try { + const carrier = scopeTarget(this, exec.agent) + const result = await this.ctx.waterfall( + carrier, 'tools/execute', exec, + async (): Promise => { + try { + const tool = this.get(exec.name, exec.agent) + if (!tool) throw new ToolNotFoundError(exec.name) + const returned = await tool.execute(exec.arguments, exec) + const content = Array.isArray(returned) ? returned : returned.content + const meta = Array.isArray(returned) ? undefined : returned.meta + return { content, isError: false, ...meta !== undefined ? { meta } : {} } + } catch (error: unknown) { + return toolErrorResult(error) + } + }, + ) + const deferredContexts = this.deferredContexts.get(exec) + /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ + if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') + const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 + ? result + : { + ...result, + additionalContexts: [ + ...deferredContexts, + ...result.additionalContexts ?? [], + ], } - }, - ) - const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? result - : { - ...result, - additionalContexts: [ - ...deferredContexts, - ...result.additionalContexts ?? [], - ], - } - return await this.postExecute(exec, resultWithDeferredContexts) + return { kind: 'post-result', result: resultWithDeferredContexts } + } catch (error: unknown) { + return { kind: 'final-result', result: toolErrorResult(error) } + } } - /** Notify final-result observers without giving them a mutation/error channel into the outcome. */ + /** + * Run ordered post-execute, then materialize and notify the final outcome. + * @param exec - the prepared execution. + * @param result - dispatch/pre result that still needs post-execute. + * @returns the materialized final result. + * @internal + */ + private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise { + try { + return this.finishScheduledExecution(exec, await this.postExecute(exec, result)) + } catch (error: unknown) { + return this.finishScheduledExecution(exec, toolErrorResult(error)) + } + } + + /** + * Materialize and notify a final result that must bypass post-execute. + * @param exec - the prepared execution. + * @param result - final result. + * @returns the materialized final result. + * @internal + */ + private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + let finalResult: ToolExecutionResult + try { + finalResult = this.materializeFinalResult(result) + } catch (error: unknown) { + finalResult = this.materializeFinalResult(toolErrorResult(error)) + } + this.notifyResult(exec, finalResult) + return finalResult + } + + /** Notify observers without exposing a mutation or error channel into the outcome. */ private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { - // The pipeline is over: freeze the remaining mutable signal slot so every - // observer sees the SAME WeakMap-keyable execution without a mutation race. + // Freeze the remaining mutable signal slot before observers receive the + // shared WeakMap-keyable execution object. Object.freeze(exec) const callbacks = this.ctx.events.dispatch('emit', [ scopeTarget(this, exec.agent), 'tools/result', exec, result, diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 9b8510a768..f2b62669b1 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -21,12 +21,8 @@ export interface SchemaProp { /** Enum of allowed values (strings only). */ enum?: string[] /** - * Default value, emitted into the JSON Schema only (validation never applies - * it — see the validator note below). - * - * XXX(unused-default): no tool definition in the repo sets `default`; it rides - * into the wire schema for a model that no tool surfaces it to. Drop the field - * and its converter line unless a real tool needs a model-visible default. + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. */ default?: unknown /** Nested properties for type: 'object'. */ @@ -283,6 +279,14 @@ export interface DefineToolOptions { * is never sent to the model. */ readonly timeoutMs?: number + /** + * Optional pure synchronous classifier for sibling overlap. It receives typed + * arguments after soft validation; invalid input returns `false` without + * invoking it. See {@link ToolDefinition.isConcurrencySafe}. + * @param args - typed validated arguments. + * @returns whether this call may join a parallel group. + */ + isConcurrencySafe?(args: InferArgs): boolean /** * Tool execution function. `args` is typed as {@link InferArgs} — zero * casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing @@ -315,7 +319,7 @@ export interface DefineToolOptions { * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. * @returns a registry-ready definition with strict execution validation and - * soft presenter validation for replay compatibility. + * soft presenter and classifier validation for replay compatibility. */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. @@ -325,6 +329,8 @@ export function defineTool(options: DefineToolOptions): const userPresentCall = options.presentCall // eslint-disable-next-line @typescript-eslint/unbound-method const userPresentResult = options.presentResult + // eslint-disable-next-line @typescript-eslint/unbound-method + const userIsConcurrencySafe = options.isConcurrencySafe if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) { throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`) } @@ -359,5 +365,12 @@ export function defineTool(options: DefineToolOptions): return userPresentResult(args as InferArgs, result) } } + // Invalid arguments fail closed without invoking the typed classifier. + if (userIsConcurrencySafe) { + tool.isConcurrencySafe = (args: unknown): boolean => { + if (validateArgs(options.parameters, args).length > 0) return false + return userIsConcurrencySafe(args as InferArgs) + } + } return tool } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index d4660f7ed7..bbc19d5d75 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -59,7 +58,7 @@ async function setup(options: SetupOptions = {}) { /** Mint one production-shaped agent scope that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { - const agent = { id: AgentId(name) } as Agent + const agent = { id: SessionId(name) } as Agent let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools', 'systemPrompt'] })) diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts new file mode 100644 index 0000000000..9a12f33a51 --- /dev/null +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -0,0 +1,136 @@ +/** Covers fail-closed per-call classification and model-schema isolation. */ + +import { describe, expect, expectTypeOf, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { + defineTool, + type ToolDefinition, + type ToolExecutionInput, + type ToolExecutionMode, +} from '@deepseek-ai/dsh-tools' + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +function exec(name: string, args: unknown): ToolExecutionInput { + return { callId: CallId('c1'), name, arguments: args } +} + +describe('ToolRegistry.executionMode', () => { + it('returns parallel only for an explicit true classifier', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'safe', + description: 'parallel-safe', + parameters: {}, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) + }) + + it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'plain', + description: 'no declaration', + parameters: {}, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('returns exclusive for an unknown tool', async () => { + const ctx = await setup() + expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('returns exclusive when the classifier returns false for these args', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'rw', + description: 'read or write', + parameters: { mode: { type: 'string', required: true } }, + isConcurrencySafe: args => args.mode === 'read', + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) + expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) + }) + + it('classifies invalid defineTool arguments as exclusive without throwing', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'needs-mode', + description: 'requires mode', + parameters: { mode: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('treats a throwing raw classifier as exclusive', async () => { + const ctx = await setup() + const raw: ToolDefinition = { + name: 'thrower', + description: 'classifier throws', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe() { throw new Error('boom') }, + async execute() { return [] }, + } + ctx.tools.register(raw) + expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('treats a truthy non-boolean raw result as exclusive', async () => { + const ctx = await setup() + const raw = { + name: 'truthy', + description: 'classifier returns a truthy string', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe() { return 'yes' }, + async execute() { return [] }, + } as unknown as ToolDefinition + ctx.tools.register(raw) + expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + }) + + it('passes parsed arguments directly to a raw definition', async () => { + const ctx = await setup() + let seen: unknown + ctx.tools.register({ + name: 'raw-safe', + description: 'raw', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe(args) { seen = args; return true }, + async execute() { return [] }, + }) + expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) + expect(seen).toEqual({ anything: 1 }) + }) + + it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => { + const ctx = await setup() + ctx.tools.register(defineTool({ + name: 'safe', + description: 'parallel-safe', + parameters: { x: { type: 'string', required: true } }, + isConcurrencySafe: () => true, + async execute() { return [] }, + })) + const schema = ctx.tools.schemas()[0] as unknown as Record + expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters']) + expect(schema.isConcurrencySafe).toBeUndefined() + }) + + it('ToolExecutionMode is the object-tagged union', () => { + expectTypeOf().toEqualTypeOf<{ kind: 'parallel' } | { kind: 'exclusive' }>() + }) +}) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index d4851cdbd7..49ffc0bac9 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' + import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { @@ -20,7 +22,7 @@ async function mount(): Promise { /** Mint a scope whose key doubles as a minimal Agent-like object. */ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> { - const key = { id: name as AgentId } as Agent + const key = { id: name as SessionId } as Agent let scope!: Scope // The scoped context resolves services through the MINTING plugin's // dependency chain — the minter must inject what scope holders will reach @@ -62,7 +64,7 @@ describe('scoped tool registration', () => { it('files a scoped tool in its layer: visible/executable for that scope only', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('shared')) scope.ctx.tools.register(tool('mine')) @@ -195,7 +197,7 @@ describe('scoped execution dispatch', () => { it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('t')) const seen: (string | undefined)[] = [] @@ -213,7 +215,7 @@ describe('scoped execution dispatch', () => { it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent let bodyCalls = 0 ctx.tools.register({ ...tool('t'), @@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => { it('uses one input snapshot for the normalized error shell', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'accepted') - const driftAgent = { id: 'drift' as AgentId } as Agent + const driftAgent = { id: 'drift' as SessionId } as Agent ctx.tools.register(tool('parent')) ctx.tools.register(tool('t')) let parent!: ToolExecutionToken diff --git a/packages/examples/README.md b/packages/examples/README.md index 65dfa40329..5703039d94 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,11 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + workspace-context + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | +| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `stdio-demo`, `cli-demo`, and `acp-demo` compose it with terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index bf6c00e3ce..9ef18739c8 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -27,6 +27,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron |---|---|---| | `provider` | (required) | the initial provider route for each per-session agent the bridge creates; ACP model selection may replace it per session | | `model` | (required) | the initial model for each per-session agent; ACP clients may switch among adapter-advertised models | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | | `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | @@ -54,6 +55,10 @@ All diagnostics go to **stderr** — stdout is the protocol. Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **JSONL persistence is baked in** — config chooses its root but cannot select a different backend; that requires a sibling entry or differently composed app package. diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 257aae5b12..6785e5f957 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -19,6 +19,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' /** * App config: the swappable per-deployment values. `provider` and `model` configure the @@ -34,6 +35,8 @@ export interface Config { provider: string /** Model name for ACP-created agents (must have a registered adapter). */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -60,6 +63,7 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic // order" (the owning dsh-system-prompt schema does the same), while @@ -67,9 +71,7 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, dshHome: z.string(), - // TODO(single-default-literal): share this schema default and the defensive - // apply() fallback through one named constant while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, @@ -87,6 +89,6 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { ctx.plugin(agentCore, agentCore.pickSpineConfig(config)) ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(acp, { provider: config.provider, model: config.model }) } diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index e717cf57fa..10ee749f8c 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -83,7 +83,7 @@ describe('dsh-acp-demo composition', () => { }) it('defaults the persistence root when omitted', async () => { - // Exercises the `?? './.sessions'` fallback for a direct-apply caller that + // Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that // bypasses the schema's `.default(...)`: call `apply` directly (not via // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. @@ -127,6 +127,19 @@ describe('dsh-acp-demo composition', () => { await ctx.fiber.dispose() }) + it('forwards maxParallelToolCalls to the bundled agent loop', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + maxParallelToolCalls: 3, + persistenceRoot: '/tmp/dsh-acp-demo-test-parallel', + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 45797a58c8..f7a0f28cea 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -16,7 +16,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider -@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary +@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events @deepseek-ai/dsh-tasks generic background-task registry @deepseek-ai/dsh-invariants dev-mode event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash schema @@ -34,7 +34,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). +- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. @@ -42,11 +42,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. +The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — a stdio app pre-creates `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer, while `toolTasks` controls generic `task_output` wait bounds; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields. ## Why a code bundle, not a shared YAML include @@ -56,6 +56,10 @@ A YAML include can deduplicate config but cannot own a bin or provide front-door Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index d8357c6c9a..a4965ca457 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -57,6 +57,8 @@ export interface SkillConfig { export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** Agent-loop concurrency cap; `1` is serial. */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -109,6 +111,7 @@ export const Config = z.intersect([ */ export function pickSpineConfig(config: Omit): Omit { return { + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, @@ -160,5 +163,8 @@ export function apply(ctx: Context, config: Config): void { // rendered order, so workspace instructions must precede the skill catalog. ctx.plugin(toolSkill, config.skills?.tool ?? {}) ctx.plugin(toolTasks, config.toolTasks ?? {}) - ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) + ctx.plugin(AgentLoop, { + agents: config.agents ?? [], + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + }) } diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 3629fdd838..5d5b336ff9 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -86,10 +86,10 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -function waitForMainIdle(ctx: Context): Promise { +function waitForIdle(ctx: Context, target: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (agent, status) => { - if (agent.id === 'main' && status === 'idle') { + if (agent === target && status === 'idle') { dispose() resolve() } @@ -129,22 +129,34 @@ describe('dsh-agent-spine-demo bundle', () => { it('defaults the agents list to empty (no pre-created agents)', async () => { const ctx = await mount({ workspaceContext: false }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined() await ctx.fiber.dispose() }) it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock' }], + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }], persona: 'You are main.', workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) const assembly = await ctx.get('systemPrompt')!.assemble() expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') await ctx.fiber.dispose() }) + it('forwards the global maxParallelToolCalls config to agent-loop', async () => { + const ctx = await mount({ + agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock' }], + maxParallelToolCalls: 3, + workspaceContext: false, + }) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. @@ -168,7 +180,6 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('main-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, @@ -176,7 +187,7 @@ describe('dsh-agent-spine-demo bundle', () => { const agent = handle.agent agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') expect(sentText).toContain('hi') @@ -199,14 +210,13 @@ describe('dsh-agent-spine-demo bundle', () => { const ctx = await mount({ workspaceContext: { maxBytes: 0 } }) ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('main-disabled-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) await handle.dispose() @@ -289,14 +299,13 @@ describe('dsh-agent-spine-demo bundle', () => { content: 'body', }) const handle = await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId('prefix-order-session'), meta: { cwd: root }, agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill') diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md new file mode 100644 index 0000000000..760a0f60e1 --- /dev/null +++ b/packages/examples/cli-demo/README.md @@ -0,0 +1,74 @@ +# @deepseek-ai/dsh-cli-demo + +Headless one-shot app and bin for running one agent task without a readline or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits. + +The package mounts no console logger, readline UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr. + +## Config + +| Key | Default | Routed to | +|---|---|---| +| `provider` | required | the configured agent's provider route | +| `model` | required | the configured agent's model | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap; `1` is serial | +| `persona` | — | the deployment persona in `dsh-system-prompt` | +| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` | +| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | +| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool | +| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in | +| `toolTasks` | owner defaults | generic `task_output` wait bounds | +| `persistenceRoot` | `./.sessions` | JSONL session root | +| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading | + +## CLI contract + +```sh +dsh-cli-demo [--config path] [--output-format text|json|stream-json] +``` + +`--config` defaults to `./cordis.yml`; `--output-format` defaults to `text`. Exactly one nonblank positional task is required, so quote tasks containing spaces. `--help` prints usage without booting. There is no `-p` or `--print` flag. + +The root headless-agent example supplies its leaf: + +```sh +pnpm run demo:headless -- "inspect the failing test and fix it" +``` + +Loader configs with bare package specifiers require `node --expose-internals` or the Loader's optional native fallback. The root command supplies the Node flag. + +### Output formats + +- `text` writes the last assistant message containing text, followed by one newline. +- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn. +- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results. + +Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively. + +The task turn is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits. + +## Operational safety + +The headless-agent leaf supplies local bash, filesystem, skill, subagent, workflow, and todo capabilities. A task can therefore mutate the launch workspace, run commands, spawn child agents, and consume provider tokens. Run the CLI from the intended project directory, review the leaf's capability and sandbox configuration, and do not treat non-interactive execution as an approval boundary. + +## Model Experience + +### One-shot task turn + +#### What the model sees + +The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn. + +#### Token effect + +The task, prompt sections, tool schemas, assistant output, and tool results consume tokens on each model step. JSON event streaming and final rendering add no model tokens; delegated child work has its own model usage and is not included in the parent result's `usage` total. + +#### KV Cache effect + +Tool-round history is append-only while the one-shot agent's prompt, schemas, model route, and session prefix remain fixed. Changing that composition establishes a different request prefix; JSON output mode has no cache effect. + +## Known Limitations and Deferred Work + +- **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app. +- **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy. +- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn. diff --git a/packages/examples/cli-demo/package.json b/packages/examples/cli-demo/package.json new file mode 100644 index 0000000000..7b035e2524 --- /dev/null +++ b/packages/examples/cli-demo/package.json @@ -0,0 +1,61 @@ +{ + "name": "@deepseek-ai/dsh-cli-demo", + "description": "Headless one-shot agent app with text and DSH-native JSON output", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-cli-demo": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", + "@deepseek-ai/dsh-app-boot": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-workspace-context": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.17.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "cordis": "^4.0.0-rc.7", + "schemastery": "^3.17.0" + } +} diff --git a/packages/examples/cli-demo/src/bin.ts b/packages/examples/cli-demo/src/bin.ts new file mode 100644 index 0000000000..5b6638ef72 --- /dev/null +++ b/packages/examples/cli-demo/src/bin.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Process wrapper for `dsh-cli-demo`; covered parsing and task execution live in + * `cli.ts` while this entry owns Unix signal-to-exit-code mapping. + * @module @deepseek-ai/dsh-cli-demo/bin + */ + +import { installFailLoud } from '@deepseek-ai/dsh-app-boot' +import { executeCli } from './cli.ts' + +const NAME = 'dsh-cli-demo' + +/* v8 ignore start -- thin self-executing process glue; built-bin tests exercise + real argv, signals, Loader boot, output, and exit codes */ +const abort = new AbortController() +let signalExitCode: number | undefined +const interrupt = (signal: 'SIGINT' | 'SIGTERM', code: number): void => { + signalExitCode ??= code + if (!abort.signal.aborted) abort.abort(`received ${signal}`) +} +const onSigint = (): void => { interrupt('SIGINT', 130) } +const onSigterm = (): void => { interrupt('SIGTERM', 143) } +const uninstallFailLoud = installFailLoud(NAME) +process.on('SIGINT', onSigint) +process.on('SIGTERM', onSigterm) +try { + const code = await executeCli(process.argv.slice(2), { signal: abort.signal }) + process.exitCode = signalExitCode ?? code +} finally { + process.off('SIGINT', onSigint) + process.off('SIGTERM', onSigterm) + uninstallFailLoud() +} +/* v8 ignore stop */ diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts new file mode 100644 index 0000000000..68c9598e6c --- /dev/null +++ b/packages/examples/cli-demo/src/cli.ts @@ -0,0 +1,447 @@ +/** + * Command parser and one-turn driver for `dsh-cli-demo`. The executable wrapper + * owns process signals; this module owns output, durability, and cleanup. + * @module @deepseek-ai/dsh-cli-demo/cli + */ + +import { parseArgs } from 'node:util' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TokenUsage } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' +import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const CLI_NAME = 'dsh-cli-demo' +const DEFAULT_CONFIG_PATH = './cordis.yml' +const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const +const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] \n` + +/** Supported CLI output encodings. */ +export type OutputFormat = typeof OUTPUT_FORMATS[number] + +/** Parsed command: help exits before boot; run carries one validated task. */ +export type CliCommand = + | { readonly kind: 'help' } + | { + readonly kind: 'run' + readonly configPath: string + readonly outputFormat: OutputFormat + readonly task: string + } + +/** DSH-native final record emitted by JSON modes. */ +export interface CliResult { + readonly type: 'result' + readonly success: boolean + readonly sessionId: string + readonly turn: number + readonly result: string + readonly reason: TurnEndReason + readonly usage?: TokenUsage +} + +/** Options for one turn against the configured top-level agent. */ +export interface OneShotOptions { + /** Exactly one nonblank user task. */ + readonly task: string + /** Optional signal that cancels the selected agent. */ + readonly signal?: AbortSignal + /** Synchronous task-turn observer; a throw cancels the agent and fails the run after flush. */ + readonly onEvent?: (sessionId: string, event: SessionEvent) => void +} + +/** Injectable process boundaries used by {@link executeCli}. */ +export interface CliRuntime { + /** Process cwd for config resolution and `.env` loading. */ + readonly cwd?: string + /** Cancellation signal, normally aborted by SIGINT or SIGTERM. */ + readonly signal?: AbortSignal + /** Loader boot boundary. */ + readonly boot?: (name: string, absoluteConfigPath: string) => Promise + /** Optional `.env` loader boundary. */ + readonly loadEnv?: (name: string, dir: string, warn: (line: string) => void) => void + /** Stdout sink; throws are treated as output failures. */ + readonly writeStdout?: (chunk: string) => unknown + /** Stderr diagnostic sink. */ + readonly writeStderr?: (chunk: string) => unknown + /** Context disposal boundary. */ + readonly dispose?: (ctx: Context) => Promise +} + +interface ParsedArguments { + readonly values: { + readonly config?: string + readonly 'output-format'?: string + readonly help?: boolean + } + readonly positionals: string[] +} + +class CliArgumentError extends Error { + constructor(message: string) { + super(message) + this.name = 'CliArgumentError' + } +} + +class CliInterruptedError extends Error { + constructor(reason: string) { + super(reason) + this.name = 'CliInterruptedError' + } +} + +/** Render an arbitrary value without trusting its type traps or string coercion. */ +function renderUnknown(value: unknown): string { + try { + return String(value) + } catch { + return '[unrenderable thrown value]' + } +} + +/** Normalize an arbitrary thrown value without letting inspection escape containment. */ +function toError(error: unknown): Error { + try { + if (error instanceof Error) return error + } catch { + // A hostile proxy may throw during instanceof; use the total renderer below. + } + return new Error(renderUnknown(error)) +} + +function interruptionReason(signal: AbortSignal): string { + return signal.reason === undefined ? 'interrupted' : renderUnknown(signal.reason) +} + +/** + * Parse the bin arguments and enforce the one-positional-task contract. + * @param args - arguments after the executable name. + * @returns a help or run command. + * @throws {@link CliArgumentError} for unknown flags, invalid formats, or task cardinality. + */ +export function parseCliArgs(args: readonly string[]): CliCommand { + let parsed: ParsedArguments + try { + parsed = parseArgs({ + args: [...args], + options: { + config: { type: 'string' }, + 'output-format': { type: 'string' }, + help: { type: 'boolean' }, + }, + allowPositionals: true, + strict: true, + }) + } catch (error: unknown) { + throw new CliArgumentError(toError(error).message) + } + + if (parsed.values.help === true) return { kind: 'help' } + if (parsed.positionals.length !== 1) { + throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`) + } + // Cardinality was checked above, so index zero exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const task = parsed.positionals[0]! + if (task.trim().length === 0) throw new CliArgumentError('task must not be blank') + + const requestedFormat = parsed.values['output-format'] ?? 'text' + if (!OUTPUT_FORMATS.some(format => format === requestedFormat)) { + throw new CliArgumentError(`unsupported output format ${JSON.stringify(requestedFormat)}`) + } + return { + kind: 'run', + configPath: parsed.values.config ?? DEFAULT_CONFIG_PATH, + outputFormat: requestedFormat as OutputFormat, + task, + } +} + +function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage { + const next: TokenUsage = { + inputTokens: (total?.inputTokens ?? 0) + step.inputTokens, + outputTokens: (total?.outputTokens ?? 0) + step.outputTokens, + } + for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) { + if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0) + } + return next +} + +function assistantText(event: Extract): string | undefined { + const blocks = event.data.content.filter(block => block.type === 'text') + return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('') +} + +/** Wait for startup quiescence while making pre-run cancellation terminal. */ +async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise { + if (signal === undefined) { + await agent.whenIdle() + return + } + if (signal.aborted) { + agent.cancel(interruptionReason(signal)) + throw new CliInterruptedError(interruptionReason(signal)) + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + agent.cancel(interruptionReason(signal)) + reject(new CliInterruptedError(interruptionReason(signal))) + } + signal.addEventListener('abort', onAbort, { once: true }) + void agent.whenIdle().then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort) + }) + }) +} + +/** + * Run one message-triggered turn on the configured top-level agent, aggregate its + * final text and model usage, wait for idle plus an explicit persistence flush, + * and return its durable ending. Only the selected agent's task turn reaches + * `onEvent`; startup injections and unrelated sessions are ignored. The context + * must contain exactly one top-level agent. Signal abort cancels that agent; an + * abort before the correlated task turn rejects. An observer throw cancels the + * turn and is rethrown after the agent reaches idle and the session flushes. + * @param ctx - settled Loader root containing one agent plus `ctx.sessions`. + * @param options - task, optional cancellation, and optional stream observer. + * @returns the DSH-native result envelope after durable quiescence. + */ +export async function runOneShot(ctx: Context, options: OneShotOptions): Promise { + const agents = ctx.get('agents')?.roots() ?? [] + const [agent] = agents + if (agent === undefined || agents.length !== 1) { + throw new Error(`config must create exactly one top-level agent, found ${agents.length}`) + } + await waitForStartupIdle(agent, options.signal) + + let targetTurn: number | undefined + let reason: TurnEndReason | undefined + let result = '' + let usage: TokenUsage | undefined + let outputError: Error | undefined + let resolveTurn!: () => void + let rejectTurn!: (error: Error) => void + let settled = false + const turnEnded = new Promise((resolve, reject) => { + resolveTurn = resolve + rejectTurn = reject + }) + + const settleResolved = (): void => { + settled = true + resolveTurn() + } + const settleRejected = (error: Error): void => { + settled = true + rejectTurn(error) + } + const observe = (sessionId: string, event: SessionEvent): void => { + if (outputError !== undefined || options.onEvent === undefined) return + try { + options.onEvent(sessionId, event) + } catch (error: unknown) { + outputError = toError(error) + agent.cancel('stream output failed') + } + } + + const disposeListener = ctx.on('session/event', (session, event) => { + if (session !== agent.session || settled) return + if (targetTurn === undefined) { + if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return + targetTurn = event.data.turn + } + observe(session.id, event) + if (event.type === 'assistant/message' && event.data.turn === targetTurn) { + result = assistantText(event) ?? result + if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage) + } + if (event.type === 'turn/end' && event.data.turn === targetTurn) { + reason = event.data.reason + settleResolved() + } + }) + + const signal = options.signal + let onAbort: (() => void) | undefined + if (signal !== undefined) { + onAbort = (): void => { + agent.cancel(interruptionReason(signal)) + if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal))) + } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes the race between startup-idle completion and listener registration */ + if (signal.aborted) onAbort() + } + + try { + /* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */ + if (!settled) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition + agent.send([{ type: 'text', text: options.task }]) + } + await turnEnded + } finally { + if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort) + disposeListener() + await agent.whenIdle() + } + + /* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */ + if (targetTurn === undefined || reason === undefined) { + throw new Error('task ended without a correlated turn/end event') + } + await ctx.sessions.flush(agent.session) + if (outputError !== undefined) throw outputError + return { + type: 'result', + success: reason.kind === 'completed', + sessionId: agent.session.id, + turn: targetTurn, + result, + reason, + ...usage === undefined ? {} : { usage }, + } +} + +function renderResult(outputFormat: OutputFormat, result: CliResult): string { + return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n` +} + +/** + * Race Loader boot with cancellation without abandoning a context that becomes + * available after the caller has been released. Waiting for that late context + * would recreate the signal hang, so its disposal and diagnostics run detached. + */ +async function bootInterruptibly( + start: () => Promise, + signal: AbortSignal | undefined, + disposeLateContext: (ctx: Context) => Promise, + reportLateDisposalFailure: (error: unknown) => void, +): Promise { + if (signal === undefined) return await start() + if (signal.aborted) throw new CliInterruptedError(interruptionReason(signal)) + + let onAbort!: () => void + const interruptedBoot = new Promise((_resolve, reject) => { + onAbort = (): void => { + reject(new CliInterruptedError(interruptionReason(signal))) + } + signal.addEventListener('abort', onAbort, { once: true }) + /* v8 ignore next -- closes registration against a non-standard synchronously mutating signal */ + if (signal.aborted) onAbort() + }) + const booting = Promise.resolve().then(start) + try { + return await Promise.race([booting, interruptedBoot]) + } catch (error: unknown) { + // The awaited race permits the signal to change after the preflight check. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (signal.aborted) { + void booting.then( + async (lateContext) => { + try { + await disposeLateContext(lateContext) + } catch (error: unknown) { + reportLateDisposalFailure(error) + } + }, + () => {}, + ) + } + throw error + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +/** + * Render a non-completed turn reason for stderr. + * @param reason - durable turn ending to describe. + * @returns a concise diagnostic fragment. + */ +export function formatTurnFailure(reason: TurnEndReason): string { + switch (reason.kind) { + case 'completed': return 'completed' + case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}` + case 'error': return `failed at step ${reason.step}: ${reason.message}` + case 'disposed': return 'was disposed' + case 'max-tokens': return 'reached the model output-token limit' + case 'rejected': return `was rejected: ${reason.reason}` + case 'interrupted': return 'was interrupted during persistence recovery' + default: return `ended with ${JSON.stringify(reason)}` + } +} + +/** + * Execute one CLI invocation. Argument and boot failures never write stdout; + * context disposal is awaited before return, and its failure does not replace + * an earlier diagnostic. + * @param args - arguments after the executable name. + * @param runtime - optional injected process boundaries for tests and embedding. + * @returns the ordinary process exit code; the thin bin overrides it for Unix signals. + */ +export async function executeCli(args: readonly string[], runtime: CliRuntime = {}): Promise { + /* v8 ignore next -- default process sinks are exercised by the built-bin smoke */ + const writeStdout = runtime.writeStdout ?? (chunk => process.stdout.write(chunk)) + /* v8 ignore next -- default process sinks are exercised by the built-bin smoke */ + const writeStderr = runtime.writeStderr ?? (chunk => process.stderr.write(chunk)) + let command: CliCommand + try { + command = parseCliArgs(args) + } catch (error: unknown) { + writeStderr(`${CLI_NAME}: ${toError(error).message}\n${USAGE}`) + return 1 + } + if (command.kind === 'help') { + writeStdout(USAGE) + return 0 + } + + /* v8 ignore next -- default process cwd is exercised by the built-bin smoke */ + const cwd = runtime.cwd ?? process.cwd() + /* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */ + const loadEnvironment = runtime.loadEnv ?? loadEnv + /* v8 ignore next -- default env/boot boundaries are exercised by the Loader and built-bin smokes */ + const bootContext = runtime.boot ?? boot + /* v8 ignore next -- default disposal is exercised by the built-bin smoke */ + const disposeContext = runtime.dispose ?? (target => target.fiber.dispose()) + let ctx: Context | undefined + let exitCode = 1 + let diagnostic: string | undefined + try { + loadEnvironment(CLI_NAME, cwd, line => writeStderr(line)) + ctx = await bootInterruptibly( + () => bootContext(CLI_NAME, resolveConfigPath(command.configPath, undefined, cwd)), + runtime.signal, + disposeContext, + error => writeStderr(`${CLI_NAME}: dispose after interrupted boot failed: ${toError(error).message}\n`), + ) + const result = await runOneShot(ctx, { + task: command.task, + ...runtime.signal === undefined ? {} : { signal: runtime.signal }, + ...command.outputFormat === 'stream-json' + ? { onEvent: (sessionId: string, event: SessionEvent) => { + writeStdout(`${JSON.stringify({ type: 'session_event', sessionId, event })}\n`) + } } + : {}, + }) + writeStdout(renderResult(command.outputFormat, result)) + exitCode = result.success ? 0 : 1 + if (!result.success) diagnostic = `${CLI_NAME}: turn ${result.turn} ${formatTurnFailure(result.reason)}\n` + } catch (error: unknown) { + diagnostic = `${CLI_NAME}: ${toError(error).message}\n` + } finally { + if (ctx !== undefined) { + try { + await disposeContext(ctx) + } catch (error: unknown) { + diagnostic = `${diagnostic ?? ''}${CLI_NAME}: dispose failed: ${toError(error).message}\n` + exitCode = 1 + } + } + } + if (diagnostic !== undefined) writeStderr(diagnostic) + return exitCode +} diff --git a/packages/examples/cli-demo/src/index.ts b/packages/examples/cli-demo/src/index.ts new file mode 100644 index 0000000000..1308209681 --- /dev/null +++ b/packages/examples/cli-demo/src/index.ts @@ -0,0 +1,82 @@ +/** + * Headless one-shot app composition: the default agent spine, JSONL session + * persistence, and one fresh top-level agent. The CLI driver owns task + * submission and output; the app deliberately mounts no interactive or logging + * front door so stdout remains protocol-pure. + * @module @deepseek-ai/dsh-cli-demo + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { SessionId } from '@deepseek-ai/dsh-session' +import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' + +const DEFAULT_PERSISTENCE_ROOT = './.sessions' + +export const name = 'cli-demo' + +/** App config forwarded to the spine, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the configured agent. */ + provider: string + /** Model name for the configured agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task control-tool config forwarded through agent-spine-demo. */ + toolTasks?: NonNullable + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} + +// Each front door keeps a complete Loader schema so its deployment contract is +// readable without a cross-package config facade. +/* jscpd:ignore-start */ +export const Config: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + persona: z.string(), + dshHome: z.string(), + skills: agentCore.SkillConfigSchema, + // Absent means lexicographic order; schemastery's native array default is []. + toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRegistry.Config, + toolBash: agentCore.ToolBashConfigSchema, + toolTasks: agentCore.ToolTasksConfigSchema, + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), +}) +/* jscpd:ignore-end */ + +/** + * Compose the UI-less spine, a fresh top-level agent rooted at the process cwd, + * and JSONL persistence. Swappable adapters, executors, and product tools stay + * in the leaf `cordis.yml`. + * @param ctx - app context that owns the composed child plugins. + * @param config - validated app configuration. + */ +export function apply(ctx: Context, config: Config): void { + ctx.plugin(agentCore, { + ...agentCore.pickSpineConfig(config), + agents: [{ id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd() }], + }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) +} diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..25043b40c4 --- /dev/null +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -0,0 +1,177 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js') +const dshPackages = [ + 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', + 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', + 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', + 'context/workspace-context', +] +const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit'] + +async function packageName(dir: string): Promise { + return (JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as { name: string }).name +} + +async function linkPackage(dir: string, nodeModules: string): Promise { + const target = join(nodeModules, await packageName(dir)) + await mkdir(dirname(target), { recursive: true }) + await symlink(dir, target) +} + +async function makeConsumer(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'cli-built-bin-')) + const nodeModules = join(dir, 'node_modules') + for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules) + for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules) + await writeFile(join(dir, 'mock-llm.mjs'), [ + "import { LlmAdapter } from '@deepseek-ai/dsh-llm'", + 'class Mock extends LlmAdapter {', + ' async * stream(options) {', + " const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''", + " yield { type: 'block-start', index: 0, blockType: 'text' }", + " if (text === 'hang') {", + " yield { type: 'text-delta', index: 0, text: 'partial' }", + ' await new Promise((resolve, reject) => {', + " const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)", + " const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }", + ' if (options.signal.aborted) onAbort()', + " else options.signal.addEventListener('abort', onAbort, { once: true })", + ' })', + ' return', + ' }', + ' const reply = `BUILT: ${text}`', + " yield { type: 'text-delta', index: 0, text: reply }", + " yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } }", + " yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }", + " yield { type: 'finish', reason: { kind: 'stop' } }", + ' }', + '}', + "export const name = 'built-cli-mock'", + "export const inject = ['llm']", + "export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }", + '', + ].join('\n')) + await writeFile(join(dir, 'cordis.yml'), [ + '- id: mock-llm', + " name: './mock-llm.mjs'", + '- id: bash', + " name: '@deepseek-ai/dsh-bash-local'", + '- id: cli-agent', + " name: '@deepseek-ai/dsh-cli-demo'", + ' config:', + ' provider: built-cli-mock', + ' model: built-cli-mock', + " persona: 'built CLI test'", + " persistenceRoot: './.sessions'", + ' workspaceContext: false', + '', + ].join('\n')) + return dir +} + +interface BinResult { + readonly code: number + readonly signal: NodeJS.Signals | null + readonly stdout: string + readonly stderr: string +} + +function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn(process.execPath, ['--expose-internals', cliBin, ...args], { + cwd, + env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let interrupted = false + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) { + interrupted = true + child.kill(interrupt) + } + }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 25_000) + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code, signal) => { + clearTimeout(timer) + resolveResult({ code: code ?? -1, signal, stdout, stderr }) + }) + }) +} + +let consumer: string | undefined + +afterEach(async () => { + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + consumer = undefined +}) + +describe.skipIf(!existsSync(cliBin))('dsh-cli-demo BUILT bin', () => { + it('runs text, json, and stream-json under plain Node and persists fresh sessions', async () => { + consumer = await makeConsumer() + const text = await runBuiltBin(consumer, ['--config', './cordis.yml', 'hello']) + expect(text).toMatchObject({ code: 0, signal: null, stdout: 'BUILT: hello\n', stderr: '' }) + + const json = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'json', 'json task']) + expect(JSON.parse(json.stdout)).toMatchObject({ + type: 'result', success: true, result: 'BUILT: json task', reason: { kind: 'completed' }, + usage: { inputTokens: 4, outputTokens: 2 }, + }) + + const stream = await runBuiltBin(consumer, ['--config', './cordis.yml', '--output-format', 'stream-json', 'stream task']) + const lines = stream.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + expect(lines[0]).toMatchObject({ type: 'session_event', event: { type: 'turn/start' } }) + expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, result: 'BUILT: stream task' }) + const files = await readdir(join(consumer, '.sessions'), { recursive: true }) + expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(3) + }, 30_000) + + it('keeps stdout empty for invalid argv and missing config', async () => { + consumer = await makeConsumer() + for (const args of [ + ['--config', './cordis.yml'], + ['--config', './cordis.yml', 'one', 'two'], + ['--config', './missing.yml', 'task'], + ]) { + const result = await runBuiltBin(consumer, args) + expect(result.code).not.toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr.length).toBeGreaterThan(0) + } + }, 30_000) + + describe.skipIf(process.platform === 'win32')('POSIX signal delivery', () => { + it.each([ + ['SIGINT', 130], + ['SIGTERM', 143], + ] as const)('cancels and disposes on %s with exit %i', async (signal, code) => { + consumer = await makeConsumer() + const result = await runBuiltBin( + consumer, + ['--config', './cordis.yml', '--output-format', 'stream-json', 'hang'], + signal, + ) + expect(result, JSON.stringify(result)).toMatchObject({ code, signal: null }) + expect(result.stdout).toContain('"kind":"aborted"') + expect(result.stderr).toContain(`received ${signal}`) + }, 30_000) + }) +}) diff --git a/packages/examples/cli-demo/tests/cli-demo.spec.ts b/packages/examples/cli-demo/tests/cli-demo.spec.ts new file mode 100644 index 0000000000..343d6184d7 --- /dev/null +++ b/packages/examples/cli-demo/tests/cli-demo.spec.ts @@ -0,0 +1,159 @@ +import { mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { CallId, type Message } from '@deepseek-ai/dsh-llm' +import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as cliDemo from '../src/index.ts' + +const contexts: Context[] = [] + +async function skillConfig(catalogDescriptionMaxLength?: number): Promise> { + const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-skills-')) + return { + local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') }, + ...catalogDescriptionMaxLength === undefined ? {} : { tool: { catalogDescriptionMaxLength } }, + } +} + +async function mount(config: cliDemo.Config, withBash = false): Promise { + const ctx = new Context() + if (withBash) ctx.provide('bash', { sandboxMode: undefined }) + contexts.push(ctx) + await ctx.plugin(cliDemo, config) + await new Promise(resolve => setTimeout(resolve, 80)) + return ctx +} + +async function composePrefix(ctx: Context): Promise { + const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent + const empty: Message[] = [] + return await agentEvents(ctx, agent).waterfall( + 'agent/session-prefix', empty, new AbortController().signal, + () => Promise.resolve(empty), + ) +} + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('dsh-cli-demo app composition', () => { + it('composes the UI-less spine, JSONL persistence, and a main agent', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-compose-')) + const ctx = await mount({ + provider: 'mock', + model: 'mock', + persona: 'Headless.', + tools: { mode: 'native' }, + persistenceRoot: root, + skills: await skillConfig(), + workspaceContext: false, + }) + const [agent] = ctx.get('agents')?.roots() ?? [] + expect(ctx.get('agentLoop')).toBeDefined() + expect(ctx.get('sessionPersistence')).toBeDefined() + expect(agent?.session.header.cwd).toBe(process.cwd()) + expect(ctx.get('userInteraction')).toBeUndefined() + expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined() + }) + + it('covers direct-apply defaults and forwards skill and tool-order config', async () => { + const oldDshHome = process.env.DSH_HOME + const oldAgentsHome = process.env.DSH_AGENTS_HOME + const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-defaults-')) + process.env.DSH_HOME = join(home, '.dsh') + process.env.DSH_AGENTS_HOME = join(home, '.agents') + try { + const ctx = new Context() + contexts.push(ctx) + cliDemo.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false }) + await new Promise(resolve => setTimeout(resolve, 80)) + expect(ctx.get('sessionPersistence')).toBeDefined() + const [agent] = ctx.get('agents')?.roots() ?? [] + expect(agent?.session.id).toMatch(/^main-session-/) + expect(await ctx.skills.list()).toEqual([]) + } finally { + if (oldDshHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = oldDshHome + if (oldAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME + else process.env.DSH_AGENTS_HOME = oldAgentsHome + } + + const ctx = await mount({ + provider: 'mock', + model: 'mock', + toolOrder: ['zulu', TOOL_ORDER_REST], + skills: await skillConfig(6), + workspaceContext: false, + }) + ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' }) + for (const name of ['alpha', 'zulu']) { + ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] }) + } + expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...') + expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([ + 'zulu', + 'alpha', + 'skill', + 'task_kill', + 'task_list', + 'task_output', + ]) + }) + + it('forwards the complete shared spine configuration', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-home-')) + const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-agents-')) + const ctx = await mount({ + provider: 'mock', + model: 'mock', + maxParallelToolCalls: 3, + dshHome, + skills: { local: { agentsHome } }, + toolBash: { enableRunInBackground: false }, + toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, + workspaceContext: false, + }, true) + + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + const execution: ToolExecution = { + token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'], + callId: CallId('cli-demo-dsh-home'), + name: 'bash', + arguments: { command: 'true' }, + } + expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: dshHome }) + const bash = ctx.tools.schemas().find(tool => tool.name === 'bash') + expect(Object.keys((bash!.parameters as { properties: Record }).properties)) + .not.toContain('run_in_background') + + const id = ctx.tasks.start({ + kind: 'bash', + label: 'config forwarding probe', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + }) + const wait = vi.spyOn(ctx.tasks, 'wait') + await ctx.tools.execute({ + callId: CallId('cli-demo-task-config'), + name: 'task_output', + arguments: { task_id: id, wait: true }, + }) + expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined) + }) + + it('exposes the Loader-safe namespace plugin shape and schema', () => { + expect(cliDemo.name).toBe('cli-demo') + expect(cliDemo.Config).toBeDefined() + expect('default' in cliDemo).toBe(false) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(cliDemo) as Record + expect(unwrapped).toBe(cliDemo) + expect(unwrapped.name).toBe('cli-demo') + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts new file mode 100644 index 0000000000..fe61a42304 --- /dev/null +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -0,0 +1,474 @@ +import { readdir, mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk, type TokenUsage } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { afterEach, describe, expect, it } from 'vitest' +import * as cliDemo from '../src/index.ts' +import { + executeCli, + formatTurnFailure, + parseCliArgs, + runOneShot, + type CliResult, +} from '../src/cli.ts' + +type ScriptEntry = readonly StreamChunk[] | 'hang' + +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + private cursor = 0 + + constructor(private readonly script: readonly ScriptEntry[]) { + super() + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script[this.cursor++] + if (entry === undefined) throw new Error('script exhausted') + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted === true) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + for (const chunk of entry) yield chunk + } +} + +function textResponse(text: string, usage?: TokenUsage, finish: 'stop' | 'max-tokens' = 'stop'): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + ...usage === undefined ? [] : [{ type: 'usage', usage } as const], + { type: 'finish', reason: { kind: finish } }, + ] +} + +function toolResponse(usage: TokenUsage): StreamChunk[] { + const id = CallId('cli-call') + const args = JSON.stringify({ text: 'round trip' }) + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'working' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'working' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id, name: 'echo', argumentsDelta: args }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'echo', arguments: args } }, + { type: 'usage', usage }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +function reasoningResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +interface Harness { + readonly ctx: Context + readonly agent: Agent + readonly persistenceRoot: string +} + +const liveContexts: Context[] = [] + +async function harness(script: readonly ScriptEntry[]): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-')) + const skillHome = await mkdtemp(join(tmpdir(), 'dsh-cli-runner-skills-')) + const ctx = new Context() + liveContexts.push(ctx) + await ctx.plugin(cliDemo, { + provider: 'mock', + model: 'mock', + persistenceRoot: root, + skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } }, + workspaceContext: false, + }) + await new Promise(resolve => setTimeout(resolve, 80)) + ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script)) + ctx.tools.register({ + name: 'echo', + description: 'Echo text.', + parameters: { text: { type: 'string', required: true } }, + execute: async args => [{ type: 'text', text: `ECHO: ${(args as { text: string }).text}` }], + }) + const [agent] = ctx.agents.roots() + if (agent === undefined) throw new Error('test main agent missing') + return { ctx, agent, persistenceRoot: root } +} + +async function invoke( + ctx: Context, + args: readonly string[], + options: { signal?: AbortSignal; failStdout?: boolean; failDispose?: boolean } = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + let stdout = '' + let stderr = '' + const code = await executeCli(args, { + cwd: '/tmp/cli-cwd', + ...options.signal === undefined ? {} : { signal: options.signal }, + boot: async () => ctx, + loadEnv: () => {}, + writeStdout: (chunk) => { + if (options.failStdout === true) throw new Error('stdout closed') + stdout += chunk + }, + writeStderr: (chunk) => { stderr += chunk }, + ...options.failDispose === true + ? { dispose: async (target: Context) => { + await target.fiber.dispose() + throw new Error('dispose exploded') + } } + : {}, + }) + return { code, stdout, stderr } +} + +afterEach(async () => { + await Promise.all(liveContexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('parseCliArgs', () => { + it('parses defaults, explicit options, spaces, and an option-like task after --', () => { + expect(parseCliArgs(['task with spaces'])).toEqual({ + kind: 'run', configPath: './cordis.yml', outputFormat: 'text', task: 'task with spaces', + }) + expect(parseCliArgs(['--config', 'custom.yml', '--output-format', 'stream-json', 'do it'])).toEqual({ + kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it', + }) + expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' }) + expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' }) + }) + + it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => { + expect(() => parseCliArgs([])).toThrow('received 0') + expect(() => parseCliArgs([' '])).toThrow('must not be blank') + expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2') + expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format') + expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option') + }) +}) + +describe('runOneShot and executeCli', () => { + it('prints help and argument diagnostics without booting or contaminating stdout', async () => { + let booted = false + let stdout = '' + let stderr = '' + const runtime = { + boot: async (): Promise => { booted = true; throw new Error('unexpected') }, + writeStdout: (chunk: string): void => { stdout += chunk }, + writeStderr: (chunk: string): void => { stderr += chunk }, + } + expect(await executeCli(['--help'], runtime)).toBe(0) + expect(stdout).toContain('Usage: dsh-cli-demo') + stdout = '' + expect(await executeCli([], runtime)).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('received 0') + expect(booted).toBe(false) + }) + + it('leaves stdout empty for environment and boot failures and resolves the default config', async () => { + let bootPath = '' + let stderr = '' + const code = await executeCli(['task'], { + cwd: '/tmp/cli-work', + loadEnv: (_name, _dir, warn) => { warn('env warning\n') }, + boot: async (_name, path) => { bootPath = path; throw 'boot exploded' }, + writeStdout: () => { throw new Error('stdout must stay empty') }, + writeStderr: (chunk) => { stderr += chunk }, + }) + expect(code).toBe(1) + expect(bootPath).toBe(resolve('/tmp/cli-work/cordis.yml')) + expect(stderr).toContain('env warning') + expect(stderr).toContain('boot exploded') + }) + + it('contains a thrown value whose inspection and coercion both fail', async () => { + const hostile = new Proxy({}, { + getPrototypeOf: () => { throw new Error('prototype trap escaped') }, + get: (target, key, receiver) => { + if (key === Symbol.toPrimitive) throw new Error('coercion escaped') + return Reflect.get(target, key, receiver) as unknown + }, + }) + let stdout = '' + let stderr = '' + const code = await executeCli(['task'], { + boot: async () => { throw hostile }, + loadEnv: () => {}, + writeStdout: (chunk) => { stdout += chunk }, + writeStderr: (chunk) => { stderr += chunk }, + }) + expect(code).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe('dsh-cli-demo: [unrenderable thrown value]\n') + }) + + it('interrupts Loader boot and contains every late boot outcome', async () => { + const abort = new AbortController() + const lateContext = new Context() + liveContexts.push(lateContext) + const boot = Promise.withResolvers() + const disposed = Promise.withResolvers() + let disposeCalls = 0 + let stderr = '' + const running = executeCli(['task'], { + signal: abort.signal, + boot: () => boot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { stderr += chunk }, + dispose: async (ctx) => { + disposeCalls += 1 + await ctx.fiber.dispose() + disposed.resolve(undefined) + }, + }) + abort.abort('received SIGTERM') + await expect(running).resolves.toBe(1) + expect(stderr).toContain('received SIGTERM') + expect(disposeCalls).toBe(0) + boot.resolve(lateContext) + await disposed.promise + expect(disposeCalls).toBe(1) + + const rejectedBoot = Promise.withResolvers() + const rejectedAbort = new AbortController() + const rejected = executeCli(['task'], { + signal: rejectedAbort.signal, + boot: () => rejectedBoot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: () => {}, + }) + rejectedAbort.abort('stop rejected boot') + await expect(rejected).resolves.toBe(1) + rejectedBoot.reject(new Error('late boot rejection')) + await Promise.resolve() + + let ordinaryBootStderr = '' + const ordinaryBootFailure = await executeCli(['task'], { + signal: new AbortController().signal, + boot: async () => { throw new Error('ordinary boot failure') }, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { ordinaryBootStderr += chunk }, + }) + expect(ordinaryBootFailure).toBe(1) + expect(ordinaryBootStderr).toContain('ordinary boot failure') + + const failedCleanupBoot = Promise.withResolvers() + const failedCleanupAbort = new AbortController() + const cleanupFailure = Promise.withResolvers() + const failedCleanupContext = new Context() + liveContexts.push(failedCleanupContext) + const failedCleanup = executeCli(['task'], { + signal: failedCleanupAbort.signal, + boot: () => failedCleanupBoot.promise, + loadEnv: () => {}, + writeStdout: () => {}, + writeStderr: (chunk) => { + if (chunk.includes('dispose after interrupted boot failed: late cleanup')) cleanupFailure.resolve(undefined) + }, + dispose: async (ctx) => { + await ctx.fiber.dispose() + throw new Error('late cleanup') + }, + }) + failedCleanupAbort.abort('stop failed cleanup boot') + await expect(failedCleanup).resolves.toBe(1) + failedCleanupBoot.resolve(failedCleanupContext) + await cleanupFailure.promise + }) + + it('renders text, flushes a persisted fresh session, and disposes the context', async () => { + const { ctx, agent, persistenceRoot } = await harness([textResponse('final answer')]) + const output = await invoke(ctx, ['task']) + expect(output).toEqual({ code: 0, stdout: 'final answer\n', stderr: '' }) + expect(agent.status).toBe('disposed') + const files = await readdir(persistenceRoot, { recursive: true }) + expect(files.some(file => file.endsWith('.jsonl'))).toBe(true) + }) + + it('sums usage across tool steps and selects the last text-bearing assistant message', async () => { + const first = { inputTokens: 10, outputTokens: 3, cacheReadTokens: 2, cacheWriteTokens: 1 } + const second = { inputTokens: 7, outputTokens: 5, cacheReadTokens: 4, reasoningTokens: 6 } + const { ctx } = await harness([toolResponse(first), textResponse('done', second)]) + const output = await invoke(ctx, ['--output-format', 'json', 'task']) + const result = JSON.parse(output.stdout) as CliResult + expect(output.code).toBe(0) + expect(result).toMatchObject({ type: 'result', success: true, turn: 1, result: 'done', reason: { kind: 'completed' } }) + expect(result.usage).toEqual({ + inputTokens: 17, + outputTokens: 8, + cacheReadTokens: 6, + cacheWriteTokens: 1, + reasoningTokens: 6, + }) + }) + + it('keeps the prior text when a later assistant message has no text blocks', async () => { + const { ctx } = await harness([ + toolResponse({ inputTokens: 1, outputTokens: 1 }), + reasoningResponse('reasoning only'), + ]) + const result = await runOneShot(ctx, { task: 'task' }) + expect(result.result).toBe('working') + }) + + it('streams only the correlated main message turn and then the result envelope', async () => { + const { ctx, agent } = await harness([textResponse('streamed')]) + const other = ctx.sessions.create(SessionId('unrelated')) + let injected = false + ctx.on('agent/queued', (subject) => { + if (subject !== agent || injected) return + injected = true + agent.inject([{ type: 'text', text: 'startup injection' }], { source: { kind: 'plugin', plugin: 'test' } }) + other.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } }) + other.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + const output = await invoke(ctx, ['--output-format', 'stream-json', 'task']) + const lines = output.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) + const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) + expect(lines.at(-1)).toMatchObject({ type: 'result', success: true, turn: 2, result: 'streamed' }) + expect(events[0]).toMatchObject({ type: 'turn/start', data: { turn: 2, trigger: { kind: 'message' } } }) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 2 } }) + expect(lines.slice(0, -1).every(line => line['sessionId'] === agent.session.id)).toBe(true) + expect(events.some(event => event.type === 'context/message')).toBe(false) + }) + + it('emits partial data and a diagnostic for non-completed turns', async () => { + const { ctx } = await harness([textResponse('partial', { inputTokens: 2, outputTokens: 3 }, 'max-tokens')]) + const output = await invoke(ctx, ['--output-format', 'json', 'task']) + expect(JSON.parse(output.stdout)).toMatchObject({ success: false, result: 'partial', reason: { kind: 'max-tokens' } }) + expect(output.code).toBe(1) + expect(output.stderr).toContain('output-token limit') + }) + + it('cancels an active turn, emits its durable aborted result, and disposes', async () => { + const { ctx, agent } = await harness(['hang']) + const abort = new AbortController() + let started!: () => void + const running = new Promise((resolveStarted) => { started = resolveStarted }) + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'assistant/chunk') started() + }) + const outcome = invoke(ctx, ['--output-format', 'json', 'task'], { signal: abort.signal }) + await running + abort.abort('received SIGINT') + const output = await outcome + expect(JSON.parse(output.stdout)).toMatchObject({ success: false, reason: { kind: 'aborted', reason: 'received SIGINT' } }) + expect(output.code).toBe(1) + expect(output.stderr).toContain('was aborted: received SIGINT') + expect(agent.status).toBe('disposed') + }) + + it('contains stream-writer failures, cancels, flushes, and returns the output error', async () => { + const { ctx, agent } = await harness(['hang']) + await expect(runOneShot(ctx, { + task: 'task', + onEvent: () => { throw new Error('stream sink failed') }, + })).rejects.toThrow('stream sink failed') + expect(agent.status).toBe('idle') + }) + + it('handles cancellation before submission, a missing main agent, and final-output failure', async () => { + const early = await harness([textResponse('unused')]) + const fakeSignal = { + aborted: true, + reason: undefined, + } as unknown as AbortSignal + await expect(runOneShot(early.ctx, { task: 'task', signal: fakeSignal })).rejects.toThrow('interrupted') + + const preBootAbort = new AbortController() + preBootAbort.abort('before boot completed') + const preBoot = await invoke(early.ctx, ['task'], { signal: preBootAbort.signal }) + expect(preBoot).toMatchObject({ code: 1, stdout: '' }) + expect(preBoot.stderr).toContain('before boot completed') + + const empty = new Context() + liveContexts.push(empty) + await expect(runOneShot(empty, { task: 'task' })).rejects.toThrow('exactly one top-level agent') + + const final = await harness([textResponse('answer')]) + const output = await invoke(final.ctx, ['task'], { failStdout: true }) + expect(output.code).toBe(1) + expect(output.stdout).toBe('') + expect(output.stderr).toContain('stdout closed') + expect(final.agent.status).toBe('disposed') + + const disposal = await harness([textResponse('answer')]) + const disposalOutput = await invoke(disposal.ctx, ['task'], { failDispose: true }) + expect(disposalOutput).toMatchObject({ code: 1, stdout: 'answer\n' }) + expect(disposalOutput.stderr).toContain('dispose exploded') + }) + + it('reports disposal failure alongside an earlier run failure', async () => { + const ctx = new Context() + liveContexts.push(ctx) + const output = await invoke(ctx, ['task'], { failDispose: true }) + expect(output).toEqual({ + code: 1, + stdout: '', + stderr: 'dsh-cli-demo: config must create exactly one top-level agent, found 0\n' + + 'dsh-cli-demo: dispose failed: dispose exploded\n', + }) + }) + + it('cancels startup work and queued work before the correlated turn begins', async () => { + const startup = await harness(['hang']) + let started!: () => void + const running = new Promise((resolveStarted) => { started = resolveStarted }) + startup.ctx.on('session/event', (session, event) => { + if (session === startup.agent.session && event.type === 'assistant/chunk') started() + }) + startup.agent.send([{ type: 'text', text: 'first' }]) + await running + const startupAbort = new AbortController() + const waiting = runOneShot(startup.ctx, { task: 'second', signal: startupAbort.signal }) + startupAbort.abort('cancel startup') + await expect(waiting).rejects.toThrow('cancel startup') + await startup.agent.whenIdle() + + const queued = await harness([textResponse('unused')]) + const queuedAbort = new AbortController() + queued.ctx.on('agent/queued', (agent) => { + if (agent === queued.agent) queuedAbort.abort('cancel queued') + }) + await expect(runOneShot(queued.ctx, { task: 'task', signal: queuedAbort.signal })).rejects.toThrow('cancel queued') + await queued.agent.whenIdle() + }) +}) + +describe('formatTurnFailure', () => { + it('diagnoses every durable reason and preserves merge-extensible unknowns', () => { + const cases: [TurnEndReason, string][] = [ + [{ kind: 'completed' }, 'completed'], + [{ kind: 'aborted' }, 'was aborted'], + [{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'], + [{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'], + [{ kind: 'disposed' }, 'was disposed'], + [{ kind: 'max-tokens' }, 'output-token limit'], + [{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'], + [{ kind: 'interrupted' }, 'persistence recovery'], + ] + for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected) + expect(formatTurnFailure({ kind: 'extension' } as unknown as TurnEndReason)).toContain('extension') + }) +}) diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json new file mode 100644 index 0000000000..f25b1592ca --- /dev/null +++ b/packages/examples/cli-demo/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../../vendor/schemastery" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../llm/llm" }, + { "path": "../../core/session" }, + { "path": "../../core/agent" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/tools" }, + { "path": "../agent-spine-demo" }, + { "path": "../../session-persistence/session-persistence-jsonl" }, + { "path": "../../ui/app-boot" } + ] +} diff --git a/packages/examples/cli-demo/tsdown.config.ts b/packages/examples/cli-demo/tsdown.config.ts new file mode 100644 index 0000000000..e5b164d46f --- /dev/null +++ b/packages/examples/cli-demo/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +/** Builds the plugin and executable entries from declarations emitted by `tsc -b`. */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/bin.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index 39cb4cd917..364b24550c 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -20,6 +20,10 @@ stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this bin adds none of its own. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The bin cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-jsonrpc` entry boots successfully and serves nothing. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index ba5381bbba..4eafc9e251 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-stdio-demo -The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. +The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline front door. Its `bin` boots a leaf `cordis.yml`. -It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. +It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client. ## What it bakes in @@ -10,12 +10,13 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| -| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's provider/model pair with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent | +| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | +| `@deepseek-ai/dsh-stdio` | the line-oriented channel for pipes and automation, bound to the exact app-owned agent/session identity | +| `@deepseek-ai/dsh-tui` | the fullscreen interactive channel for TTY pairs, bound to the same exact identity | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. @@ -27,6 +28,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte |---|---|---| | `provider` | (required) | the pre-created `main` agent's registered provider route | | `model` | (required) | the pre-created `main` agent's model | +| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial | | `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery | @@ -35,10 +37,11 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -| `welcome` | `ready.` | the stdin-chat banner | +| `welcome` | `ready.` | terminal banner / TUI subtitle | +| `ui` | `{ mode: 'auto' }` | terminal mode (`auto` / `readline` / `tui`) and nested TUI presentation config | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header. +Fresh terminal sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to the config-created agent and selected UI before agent-core starts; this lets either front door observe `agent-loop/config-start-failed`, and an AgentLoop-only reload restores materialized history under the same id. Readline buffers startup input until `agent/session-start`; the TUI waits to enter fullscreen until the matching root appears. A resumed run binds both components to the exact `resumeSessionId` and keeps the persisted cwd. ## The bin @@ -66,6 +69,8 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s provider: deepseek model: deepseek-v4-flash persona: 'You are a coding assistant powered by the {{model}} model.' + ui: + mode: auto ``` Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". @@ -74,18 +79,34 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ### Composed terminal agent request -**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message. +#### What the model sees -**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens. +Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. + +#### Token effect + +Child prompt and schema costs repeat per request; user input and tool history grow until compaction. Terminal banners, logger output, cards, and rendered transcripts add zero model tokens. + +#### KV Cache effect + +User and tool history is append-only while the composed prompt, schemas, child model route, and session prefix remain fixed. A composition change or compaction may invalidate reuse from its first changed token; terminal rendering has no cache effect. ### Human-answer result -**What the model sees**: Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. +#### What the model sees -**Token effect**: Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. +Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. + +#### Token effect + +Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work -- **One pre-created `main` agent drives the readline UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. +- **One pre-created `main` agent drives the selected terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. - **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. - **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index a523f61213..94554e3f8e 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", + "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", @@ -32,15 +32,17 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -50,9 +52,10 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", @@ -60,6 +63,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-stdio": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/stdio-demo/src/bin.ts index 462e821e50..3d8a0c2a33 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/packages/examples/stdio-demo/src/bin.ts @@ -2,7 +2,7 @@ /** * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs. + * dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs. * @module @deepseek-ai/dsh-stdio-demo/bin */ diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 89871cada4..531c4a3d5e 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -1,8 +1,9 @@ /** * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — a console logger, the independently - * packaged readline UI, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and a pre-created `main` agent the UI drives. + * coupled front-door cluster a terminal chat needs — TTY-selected pi-tui/readline + * presentation, JSONL session persistence, the user-interaction seam with its + * `ask_user_question` tool, and one pre-created agent whose exact shared + * agent/session identity the selected UI drives under its `main` display label. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -10,9 +11,9 @@ */ import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' @@ -21,8 +22,45 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' +import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'stdio-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_WELCOME = 'ready.' + +/** Terminal front door selected by the app bundle. */ +export type TerminalMode = 'auto' | 'readline' | 'tui' + +/** App-level terminal selection with nested TUI presentation settings. */ +export interface UiConfig { + /** Select a concrete front door or infer it from the process streams. */ + mode?: TerminalMode + /** Settings forwarded only when the pi-tui front door is selected. */ + tui?: uiTui.TuiConfig +} + +const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto') + +/** Schemastery schema for app-level terminal selection. */ +export const UiConfigSchema: z = z.object({ + mode: terminalModeSchema, + tui: uiTui.TuiConfigSchema, +}) + +/** + * Resolve the app's terminal front door. + * @param config - app-level terminal selection. + * @param isTTY - whether both process streams are interactive TTYs. + * @returns the concrete UI package to mount. + */ +export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude { + const mode = config?.mode ?? 'auto' + if (mode === 'auto') return isTTY ? 'tui' : 'readline' + if (mode === 'tui' && !isTTY) { + throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes') + } + return mode +} /** * App config: the swappable per-demo values, each routed to where the app wires @@ -32,13 +70,15 @@ export const name = 'stdio-demo' * is the explicit model-facing tool order (forwarded to the system-prompt plugin); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner. + * `welcome` is the UI banner and `ui` configures terminal mode/presentation. */ export interface Config { /** Provider route for the `main` agent. */ provider: string /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -51,6 +91,8 @@ export interface Config { persistenceRoot?: string /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ welcome?: string + /** Terminal front-door selection and pi-tui presentation settings. */ + ui?: UiConfig /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -58,7 +100,7 @@ export interface Config { /** Generic background-task control-tool config forwarded through agent-core. */ toolTasks?: NonNullable /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -70,6 +112,7 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), model: z.string().required(), + maxParallelToolCalls: z.number().step(1).min(1), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic // order" (the owning dsh-system-prompt schema does the same), while @@ -77,10 +120,9 @@ export const Config: z = z.object({ toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, dshHome: z.string(), - // TODO(single-default-literal): share these schema defaults and defensive - // apply() fallbacks through named constants while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), - welcome: z.string().default('ready.'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + welcome: z.string().default(DEFAULT_WELCOME), + ui: UiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: agentCore.ToolTasksConfigSchema, @@ -89,26 +131,51 @@ export const Config: z = z.object({ }) /** - * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this - * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is - * a leaf concern (see the module doc), so it is not mounted here. + * Compose the spine with one terminal front door. Persistence and user + * interaction mount first; the selected UI then waits on the exact session id + * and subscribes to config-start failures before agent-core starts it. Console + * logging is readline-only because fullscreen output belongs to pi-tui. The + * ask-user tool waits on the completed spine, and HMR remains a leaf concern. + * @param ctx - context receiving the app's child plugins. + * @param config - app configuration routed to the spine and front door. + * @param isTTY - whether both process streams are interactive TTYs. */ -export function apply(ctx: Context, config: Config): void { - ctx.plugin(ConsoleExporter) +export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + const mode = resolveTerminalMode(config.ui, isTTY) + if (mode === 'readline') ctx.plugin(ConsoleExporter) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) + ctx.plugin(UserInteractionService) + if (mode === 'tui') { + ctx.plugin(uiTui, { + ...config.ui?.tui, + welcome: config.welcome ?? DEFAULT_WELCOME, + sessionId, + }) + } else { + ctx.plugin(uiStdio, { + welcome: config.welcome ?? DEFAULT_WELCOME, + sessionId, + }) + } ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), agents: [{ - id: AgentId('main'), + id: SessionId('main'), provider: config.provider, model: config.model, cwd: process.cwd(), - ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, }], }) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) - ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) } + +/** Compose the configured terminal front door with the agent app. */ +/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered, + and the repl-agent PTY smoke covers the interactive process path */ +export function apply(ctx: Context, config: Config): void { + composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY) +} +/* v8 ignore stop */ diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index ecbff63d2f..c8ca063151 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -4,14 +4,15 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' /** - * Unit coverage for app composition and config forwarding: console logger, pre-created main agent, - * agent-spine-demo spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the + * Unit coverage for app composition and config forwarding: pre-created main agent, + * agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise * survive namespace collapse while silently losing its schema. */ @@ -65,6 +66,63 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } describe('dsh-stdio-demo app', () => { + it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => { + expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline') + expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui') + expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline') + expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui') + expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout') + }) + + it('binds only the selected terminal package to the app-owned exact session identity', () => { + const calls: Array<{ name: string; config: unknown }> = [] + const ctx = { + plugin(plugin: { name?: string }, config?: unknown) { + calls.push({ name: plugin.name ?? '', config }) + }, + } as unknown as Context + + stdioAgent.composeTerminalApp(ctx, { + provider: 'mock', + model: 'mock', + workspaceContext: false, + welcome: 'TUI ready', + ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, + }, true) + expect(calls.map(call => call.name)).toContain('ui-tui') + expect(calls.map(call => call.name)).not.toContain('ui-stdio') + expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') + const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string } + expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 }) + expect(tuiConfig.sessionId).toMatch(/^main-session-/) + const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as { + agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }> + } + expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId }) + + calls.length = 0 + stdioAgent.composeTerminalApp(ctx, { + provider: 'mock', + model: 'mock', + resumeSessionId: 'persisted-session', + workspaceContext: false, + ui: { mode: 'tui' }, + }, true) + expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({ + sessionId: 'persisted-session', welcome: 'ready.', + }) + expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0]) + .toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' }) + + calls.length = 0 + stdioAgent.composeTerminalApp(ctx, { + provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' }, + }, false) + expect(calls.map(call => call.name)).toContain('ui-stdio') + expect(calls.map(call => call.name)).toContain('ConsoleExporter') + expect(calls.map(call => call.name)).not.toContain('ui-tui') + }) + it('composes the spine + front-door cluster and pre-creates the main agent', async () => { const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false }) // The spine services (brought up by the agent-spine-demo bundle) are all present. @@ -73,24 +131,44 @@ describe('dsh-stdio-demo app', () => { expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The pre-created `main` agent the UI drives. - const agent = ctx.get('agents')?.get(AgentId('main')) + // The sole pre-created agent the UI drives. `main` is its stable config + // label; each fresh process mints a durable combined agent/session id. + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + const agent = ctx.get('agents')?.list()[0] expect(agent).toBeDefined() + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) await ctx.fiber.dispose() }) + it('normalizes an empty resume id to a fresh exact app identity', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + resumeSessionId: '', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect(agent?.id).toBe(agent?.session.id) + await ctx.fiber.dispose() + }) + it('defaults persistenceRoot and welcome when omitted', async () => { // Direct apply (NOT via ctx.plugin, which validates+defaults the config - // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on + // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on // apply()'s last two lines are the ones that fire — covering a // schema-bypassing direct-mount caller. const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false }) - await new Promise(resolve => setTimeout(resolve, 80)) + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) await ctx.fiber.dispose() }) @@ -102,7 +180,8 @@ describe('dsh-stdio-demo app', () => { persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context', workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) + expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/) await ctx.fiber.dispose() }) @@ -119,7 +198,7 @@ describe('dsh-stdio-demo app', () => { it('forwards resumeSessionId onto the pre-created agent when set', async () => { // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no `main` agent registers — + // session the resume is contained + logged, so no agent registers — // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ provider: 'mock', @@ -130,7 +209,7 @@ describe('dsh-stdio-demo app', () => { skills: await isolatedSkillsConfig(), workspaceContext: false, }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.list()).toEqual([]) await ctx.fiber.dispose() }) @@ -142,6 +221,19 @@ describe('dsh-stdio-demo app', () => { await ctx.fiber.dispose() }) + it('forwards maxParallelToolCalls to the bundled agent loop', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + maxParallelToolCalls: 3, + persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel', + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index be08d28f95..fc6711ffb9 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../../ui/stdio" }, + { + "path": "../../ui/tui" + }, { "path": "../../ui/tool-ask-user" }, diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 65ed76efce..60cf671a92 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -25,6 +25,10 @@ The package-root SDK surface is the default/named `LocalFileSystem` class plus ` Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index bd85be88cf..f24bd088eb 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -51,9 +51,17 @@ Because the plugin influences the world only through events, removing it does no ### Filesystem tool outcome -**What the model sees**: This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. +#### What the model sees -**Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload. +This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. + +#### Token effect + +Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 6dae32d235..4ea3ec5b9f 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -48,6 +48,10 @@ This package declares three events (see the generated [events catalog](../../../ Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md). diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 29afbebfec..3dd8da34b8 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -49,39 +49,71 @@ Search failures carry the package-owned `SearchError` (a `HarnessError` subclass ### System prompt -**What the model sees**: Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. +#### What the model sees -**Token effect**: Fixed guidance cost per request while the plugin is active. +Every request in this plugin's registration scope contains the independently registered glob and grep guidance below. Agent-scoped tool restrictions can hide either schema without removing its prompt section. -#### Glob guidance +##### Glob guidance ```markdown Use the glob tool — not shell find or ls — to discover files by path pattern. Results are sorted by modification time and include hidden and ignored files. ``` -#### Grep guidance +##### Grep guidance ```markdown Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context. ``` +#### Token effect + +Fixed guidance cost per request while the plugin is active. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + ### Tool schemas -**What the model sees**: The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible. +#### What the model sees -**Token effect**: Fixed schema cost on every request where the tools are visible. +The generated [`glob` and `grep` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs-search) while this surface is visible. + +#### Token effect + +Fixed schema cost on every request where the tools are visible. + +#### KV Cache effect + +Prefix-stable while tool visibility and definitions are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. ### Results and spill notices -**What the model sees**: `glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. +#### What the model sees -**Token effect**: Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction. +`glob` returns one path per line; `grep` groups `Line : ` matches beneath each path. Empty searches return `No files found` or `No matches found`. A capped result ends with its omission count plus the spill locator and backend retrieval hint, or says the complete result could not be saved. + +#### Token effect + +Inline paths and matches are bounded by `globMaxResults`, `grepMaxMatches`, and `grepMaxLineBytes`; the call and retained result remain in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Tool errors -**What the model sees**: Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. +#### What the model sees -**Token effect**: Only a failing call adds these retained tokens. +Failures are normalized as `Error: ` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers. + +#### Token effect + +Only a failing call adds these retained tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 79ebda87fb..16535c446d 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -46,57 +46,99 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. +`read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). + The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience ### System prompt -**What the model sees**: Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. +#### What the model sees -**Token effect**: Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools. +Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. -#### Read guidance +##### Read guidance ```markdown Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. ``` -#### Write guidance +##### Write guidance ```markdown Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. ``` -#### Edit guidance +##### Edit guidance ```markdown Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. ``` +#### Token effect + +Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged. Tool restrictions do not remove this section, but plugin activation or disposal may invalidate reuse from it. + ### Tool schemas -**What the model sees**: The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. +#### What the model sees -**Token effect**: Fixed schema cost on every request in that tool view. +The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. + +#### Token effect + +Fixed schema cost on every request in that tool view. + +#### KV Cache effect + +Prefix-stable while the visible tool definitions and order are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. ### Read result -**What the model sees**: A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. +#### What the model sees -**Token effect**: Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction. +A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. + +#### Token effect + +Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Write and edit results -**What the model sees**: Write returns the exact five-line envelope ``, `file`, ``, `Created file` or `Updated file`, then ``. Edit returns exactly `The file has been updated successfully.` or, for `replace_all`, `The file has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments. +#### What the model sees -**Token effect**: Success text is small, but large mutation arguments and any result are resent until compaction. +Write returns the exact five-line envelope ``, `file`, ``, `Created file` or `Updated file`, then ``. Edit returns exactly `The file has been updated successfully.` or, for `replace_all`, `The file has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments. + +#### Token effect + +Success text is small, but large mutation arguments and any result are resent until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Tool errors -**What the model sees**: Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. +#### What the model sees -**Token effect**: Only a failing call adds these retained tokens. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. + +#### Token effect + +Only a failing call adds these retained tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 63ba070126..a19b073514 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -84,6 +84,8 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, + // Observation races fail closed because guarded mutations re-check the version in-lock. + isConcurrencySafe: () => true, async execute(args, exec): Promise { const input = parseReadArgs(args, caps.limit) const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec)) diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index ca93bad63d..472b32f47a 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' @@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' @@ -65,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => try { ctx = await fsHarness(configDir, SYSTEM) const handle = await ctx.agents.create({ - agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 26a81343ff..6a9e6f3568 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -384,6 +384,33 @@ describe('signal, concurrency, and the fs/observed contract', () => { expect(onDisk === 'ONE value here' || onDisk === 'base TWO here').toBe(true) }) + it('a stale observed version from an older read fails closed at edit CAS', async () => { + await writeFile(join(dir, 'a.txt'), 'older content\n') + const target = await ctx.fs.resolve('a.txt') + const firstInfo = await ctx.fs.stat(target) + if (!firstInfo) throw new Error('expected first stat') + + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + + await writeFile(join(dir, 'a.txt'), 'newer current content\n') + const secondInfo = await ctx.fs.stat(target) + if (!secondInfo) throw new Error('expected second stat') + expect(secondInfo.version).not.toBe(firstInfo.version) + expect((await callOwned('read', { file_path: 'a.txt' })).isError).toBe(false) + + // Reproduce an older concurrent read winning the observation race. + ctx.emit('fs/observed', target, firstInfo.version, { agent: { session } }) + + const edit = await callOwned('edit', { + file_path: 'a.txt', + old_string: 'newer', + new_string: 'edited', + }) + expect(edit.isError).toBe(true) + expect(edit.error).toMatchObject({ code: 'FS_STALE_VERSION' }) + expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('newer current content\n') + }) + it('a throwing fs/observed listener surfaces as isError, but the mutation already hit disk', async () => { // fs/observed is a plain ctx.emit after the write succeeded; a throwing listener cannot // roll the write back — it only turns the tool result into isError. diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 27c2d5f672..f08db928af 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -108,6 +108,16 @@ describe('registration', () => { expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write']) }) + it('declares read parallel-safe while write/edit remain exclusive', async () => { + const { ctx } = await setup() + expect(ctx.tools.executionMode({ callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + .toEqual({ kind: 'parallel' }) + expect(ctx.tools.executionMode({ callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + .toEqual({ kind: 'exclusive' }) + expect(ctx.tools.executionMode({ callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + .toEqual({ kind: 'exclusive' }) + }) + it('registers prompt sections for each tool', async () => { const { ctx } = await setup() const prompt = renderPrompt(await ctx.systemPrompt.assemble()) diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 5a9fed247f..4c868654fc 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -24,8 +24,8 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery @@ -40,23 +40,31 @@ Unit suites drive a real agent loop against a mock adapter (no network) and cove ### First-threshold context message -**What the model sees**: At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added. +#### What the model sees -**Token effect**: Zero tokens before the threshold. The reminder is retained history for that agent. +At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added. -#### First-threshold reminder +##### First-threshold reminder ```markdown You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call. ``` +#### Token effect + +Zero tokens before the threshold. The reminder is retained history for that agent. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Later-threshold context message -**What the model sees**: A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+ more chars)`. +#### What the model sees -**Token effect**: Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters. +A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+ more chars)`. -#### Later-threshold reminder +##### Later-threshold reminder ```markdown Repeated tool call detected: @@ -66,6 +74,14 @@ Repeated tool call detected: The repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered. ``` +#### Token effect + +Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **Exact-match detection only** — canonicalization is a deep key-sort, so near-identical variants (a tweaked path, extra whitespace inside a value) evade the chain; fuzzy matching is rejected pending evidence of need. diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 9df8dd399f..2279f53ade 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,15 +1,14 @@ /** - * Advisory repeat-call loop breaker. It never registers, blocks, or rewrites a tool; configured - * consecutive canonical calls add source-attributed context after downstream post-policy. The - * loop logs that model-visible reminder as reconstructable context. Counters are per agent and - * in-memory, so one agent cannot trip another and resumed sessions start fresh. Named exports - * preserve loader metadata. See the package README for chain semantics and thresholds. + * Advisory per-agent repeat-call detector. It enriches post-execute decisions + * with logged model context without vetoing or rewriting calls. Configuration + * and chain semantics live in the package README; rationale lives in the + * repeat-tool-guard RFC. * @module @deepseek-ai/dsh-repeat-tool-guard */ import type { Context } from 'cordis' import z from 'schemastery' -import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -169,9 +168,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) } - // TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that - // removes the disposal-only status listener and cannot collide on id reuse. - const chains = new Map() + const chains = new WeakMap() /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */ function tracked(toolName: string): boolean { @@ -194,9 +191,9 @@ export function apply(ctx: Context, config: Config): void { if (!tracked(exec.name)) return undefined const canonical = canonicalize(exec.arguments) const key = JSON.stringify([exec.name, canonical]) - const chain = chains.get(exec.agent.id) + const chain = chains.get(exec.agent) const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1 - chains.set(exec.agent.id, { key, count }) + chains.set(exec.agent, { key, count }) if (!thresholdSet.has(count)) return undefined const text = count === thresholds[0] ? GENTLE_REMINDER @@ -226,12 +223,7 @@ export function apply(ctx: Context, config: Config): void { // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise => { - chains.delete(agent.id) + chains.delete(agent) return next() }) - - // Drop state when an agent goes away, bounding the map over harness lifetime. - ctx.on('agent/status', (agent, status) => { - if (status === 'disposed') chains.delete(agent.id) - }) } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 3c5c9051bf..101c542d5a 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' @@ -29,12 +29,12 @@ async function harness(config: Config = {}): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ -function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] { +function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') .map(e => ({ @@ -53,7 +53,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -74,7 +74,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -96,7 +96,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -120,7 +120,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -138,7 +138,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -159,7 +159,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -175,7 +175,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -191,7 +191,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -211,8 +211,8 @@ describe('chain semantics', () => { toolCallResponse('b3', 'probe', { q: 1 }), textResponse('done'), ])) - const agentA = ctx.agentLoop.create(AgentId('a'), { provider: 'mock-a', model: 'model-a' }) - const agentB = ctx.agentLoop.create(AgentId('b'), { provider: 'mock-b', model: 'model-b' }) + const agentA = ctx.agentLoop.create(SessionId('a'), { provider: 'mock-a', model: 'model-a' }) + const agentB = ctx.agentLoop.create(SessionId('b'), { provider: 'mock-b', model: 'model-b' }) agentA.send([{ type: 'text', text: 'go' }]) agentB.send([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) @@ -231,7 +231,7 @@ describe('chain semantics', () => { textResponse('turn two done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) agent.send([{ type: 'text', text: 'again' }]) @@ -250,16 +250,16 @@ describe('chain semantics', () => { ])) // Loop agents are torn down by disposing the scope that created them // (the loop.spec pattern): a child plugin fiber owns `first`. - let first!: ReactLoopAgent + let first!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' }) + first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() - await first.done + await first.whenIdle() - const second = ctx.agentLoop.create(AgentId('reused'), { provider: 'mock', model: 'mock' }) + const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) @@ -275,7 +275,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -291,7 +291,7 @@ describe('chain semantics', () => { toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2 textResponse('done'), ])) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -313,7 +313,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -344,7 +344,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 96a423fea9..b36059bd4f 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -33,6 +33,10 @@ Like every event they must sit inside an open turn. The mid-turn points (`PreToo Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index 4430f5511a..96c0362023 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -56,15 +56,31 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' } ### Hook-provided context -**What the model sees**: `SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target. +#### What the model sees -**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction. +`SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target. + +#### Token effect + +No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Blocked prompt or tool outcome -**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation. +#### What the model sees -**Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. +Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation. + +#### Token effect + +Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. + +#### KV Cache effect + +A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it. ## Known Limitations and Deferred Work diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 672a89188a..65dd29d146 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -4,18 +4,22 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop Claude bridge tests with a mock model, the real loop and bash - * executor, and shell hooks from a temporary config. + * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL + * bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook + * scripts written to a temp dir — only the model is mocked (the "prefer the real + * implementation" rule). Each test writes a `hooks.json` + executable scripts, + * loads the bridge pointed at them, and asserts the hook's effect on the loop. */ const dirs: string[] = [] @@ -49,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis return { ctx, hooks } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -57,7 +61,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } @@ -87,7 +91,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) @@ -110,7 +114,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -135,7 +139,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -158,7 +162,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -180,11 +184,12 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') + // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback. expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true) }) @@ -200,7 +205,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -224,7 +229,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -248,7 +253,7 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. @@ -284,17 +289,21 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, adapter) // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's - // child lookup yields undefined and it runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + // child lookup yields undefined and it simply runs the hook. + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) expect(existsSync(startMarker)).toBe(true) expect(existsSync(stopMarker)).toBe(true) - // A marker proves only that the process ran. Disposal drains its detached continuation so the - // no-context branch completes before the per-file coverage snapshot instead of racing CI. + // The markers prove the hook PROCESSES ran, not that the detached `.then` + // continuations did (`touch` lands before the process exits). Dispose drains + // them, so the no-context arm of the SubagentStart continuation — covered + // only here — executes before this file's coverage snapshot instead of + // racing it (the arm went uncovered on a loaded CI runner and failed the + // per-file 100% branch gate). await hooks.dispose() }) @@ -304,8 +313,10 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const pidFile = join(dir, 'pid') const marker = join(dir, 'started') const slowHook = join(dir, 'slow.sh') - // Record the PID and marker before sleeping past the suite timeout. Disposal must abort and - // kill the process rather than await its exit or the default ten-minute hook timeout. + // Record the hook shell's PID and touch the marker FIRST so the test can + // tell "the hook is genuinely mid-run", then sleep far past the suite + // timeout. Dispose must KILL the process (the tracker's abort signal), not + // await its exit or its 10-minute default hook timeout. writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) chmodSync(slowHook, 0o755) writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { @@ -315,15 +326,18 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() - // Disposal reaches quiescence: it returns only after the aborted run settles and the process - // is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. + // Quiescence, not just promptness: the drain resolves only after the run + // settled, and the run settles only after the killed process was reaped — + // so by the time dispose returns, the PID must be GONE (kill(pid, 0) + // throws ESRCH). An untracked fire-and-forget regression would leave the + // process alive (or unreaped) and fail this deterministically. expect(() => process.kill(pid, 0)).toThrow() - // runHook resolves an aborted run as a non-blocking error, so draining must - // not log a rejected continuation. + // The aborted run resolves as a non-blocking error (runHook never rejects), + // so the drained continuation must NOT have logged a failure. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) }) @@ -337,7 +351,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. @@ -345,8 +359,11 @@ describe('hooks-claude bridge — load resilience', () => { }) it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { - // This is the only bridge mount, and its blocking hook would veto the prompt and log an event - // if its listener leaked after disposal. A no-op hook would not expose that leak. + // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it + // would veto the prompt (0 model requests) and log a hook/invoked. Build the + // ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then + // dispose it — a leaked listener fails the test (a no-op `true` hook would + // pass even leaked, so it proved nothing). const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() @@ -356,7 +373,7 @@ describe('hooks-claude bridge — load resilience', () => { const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -364,8 +381,10 @@ describe('hooks-claude bridge — load resilience', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing - // load to fail. Guard the shape from postmortem 0001 directly. + // Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray + // `export default apply` would collapse the module via `unwrapExports` + // (`exports.default ?? exports`), DROP `inject`, and crash at load with + // "cannot get property … without inject". Guard the shape directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude') expect(HooksClaude.inject).toEqual(['bash']) diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 774e2a8451..ec7d18b4c4 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -3,13 +3,14 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -38,10 +39,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -65,7 +66,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { @@ -95,7 +96,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran @@ -111,7 +112,7 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.logger.warn = warn as never let sawArgs: unknown ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. @@ -127,7 +128,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no context/message injected. @@ -157,7 +158,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -182,7 +183,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -198,7 +199,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -214,7 +215,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. @@ -230,9 +231,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, new MockAdapter([])) // Register a fake child agent under the id the event carries. const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -246,9 +247,9 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -262,7 +263,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -276,7 +277,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -292,7 +293,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -305,7 +306,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') @@ -320,7 +321,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. @@ -335,7 +336,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -360,7 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) @@ -375,7 +376,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -390,7 +391,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -409,7 +410,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -426,7 +427,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -446,7 +447,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran @@ -464,10 +465,10 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) // NB: no projectDir // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + await waitForIdle(ctx, handle.agent) + expect(events(handle.agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -481,9 +482,8 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // the downstream block won: the model was never called, no user/message was @@ -512,7 +512,7 @@ export function defineCoverageCases(group: CoverageGroup): void { meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) @@ -541,7 +541,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -565,7 +565,7 @@ export function defineCoverageCases(group: CoverageGroup): void { meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -589,7 +589,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -613,7 +613,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -629,7 +629,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Make inject throw, forcing the SessionStart .catch path. const original = agent.inject.bind(agent) let threw = false @@ -663,9 +663,9 @@ export function defineCoverageCases(group: CoverageGroup): void { ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir const { readFileSync } = await import('node:fs') @@ -692,8 +692,8 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir @@ -713,7 +713,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) @@ -731,7 +731,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 0784857286..0e69362c36 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -60,15 +60,31 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` ### Hook-provided context -**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. +#### What the model sees -**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction. +`SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. + +#### Token effect + +No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Blocked prompt or tool outcome -**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced. +#### What the model sees -**Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. +Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced. + +#### Token effect + +Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. + +#### KV Cache effect + +A blocked prompt sends no request and invalidates nothing. Denial, feedback, and forced-continuation context append after the reusable prefix without rewriting it. ## Known Limitations and Deferred Work diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e686b565f2..2cb4cb0bc5 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -4,10 +4,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -47,14 +47,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -76,7 +76,7 @@ describe('hooks-codex bridge', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) @@ -97,7 +97,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -112,7 +112,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -122,7 +122,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() // no hooks.json written const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -142,7 +142,7 @@ describe('hooks-codex bridge', () => { const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -165,7 +165,7 @@ describe('hooks-codex bridge', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start + ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) // fires agent/session-start await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d11c74951d..a7f2e1e0ac 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -3,11 +3,11 @@ import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -34,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -62,7 +62,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { ...sessionRoot !== undefined ? { sessionRoot } : {} }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('transcript'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('transcript'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { @@ -81,7 +81,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -93,7 +93,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -106,7 +106,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) @@ -129,7 +129,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -153,7 +153,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -175,7 +175,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro meta: { owner: 'policy' }, }], })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const contexts = events(agent).filter(event => event.type === 'context/message') @@ -194,7 +194,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -207,7 +207,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -220,7 +220,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -233,7 +233,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) @@ -247,7 +247,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -258,7 +258,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -271,7 +271,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -294,7 +294,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -317,7 +317,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) @@ -330,7 +330,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -344,7 +344,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message')).toBe(false) @@ -356,7 +356,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.inject = (() => { throw new Error('inject boom') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) @@ -371,7 +371,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -384,7 +384,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) @@ -400,7 +400,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -413,7 +413,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -425,7 +425,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -442,7 +442,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') @@ -478,7 +478,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -494,7 +494,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') @@ -507,7 +507,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -521,7 +521,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) @@ -535,7 +535,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') @@ -546,7 +546,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -560,7 +560,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -575,7 +575,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') @@ -591,7 +591,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) @@ -603,7 +603,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') @@ -626,9 +626,9 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro ctx.llm.registerAdapter(['mock'], adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) await handle.dispose() diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 7da98cdebf..27bf4b626a 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -4,6 +4,8 @@ DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` A second, library-backed implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai`. This package always owns the `deepseek` provider route; mounting a pi-ai profile with `provider: deepseek` in the same context throws `LlmError('DUPLICATE_ADAPTER')` by design. +The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract. + ## Config ```yaml @@ -40,7 +42,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. ## Testing @@ -50,15 +52,31 @@ Unit suites run against a local `node:http` mock SSE server (no network). Real-A ### DeepSeek request -**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. +#### What the model sees -**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. + +#### Token effect + +Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. + +#### KV Cache effect + +An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. ### DeepSeek response -**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble. +#### What the model sees -**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. +Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble. + +#### Token effect + +Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. + +#### KV Cache effect + +Loop-retained response blocks append to the next request and preserve its earlier reusable prefix; dropped blocks have no later cache effect. Changing the provider or model selects a different cache domain. ## Known Limitations and Deferred Work diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index ece053c6b3..4f7cc66f87 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,7 +5,7 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' @@ -38,12 +38,17 @@ export interface DeepSeekAdapterOptions { /** * Map an HTTP status to a stable LlmError code. * @param status - status of a non-2xx provider response. - * @returns `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), or `HTTP_` for anything else. + * @param error - parsed provider error body, when available. + * @returns the normalized harness error code. */ -export function httpErrorCode(status: number): string { +export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' if (status === 429) return 'RATE_LIMIT' - if (status === 400) return 'INVALID_REQUEST' + if (status === 400) { + const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') + if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE + return 'INVALID_REQUEST' + } if (status >= 500) return 'SERVER' return `HTTP_${status}` } @@ -92,16 +97,17 @@ export class DeepSeekAdapter extends LlmAdapter { }) if (!response.ok) { - const code = httpErrorCode(response.status) let message = `DeepSeek API error (HTTP ${response.status})` + let providerError: WireError['error'] try { const parsed = await response.json() as WireError - if (parsed.error?.message) message = parsed.error.message + providerError = parsed.error + if (providerError?.message) message = providerError.message } catch { - // Only swallow error-body parsing: status and code are already captured, - // so malformed gateway JSON must not mask the actionable HTTP failure. + // Only swallow error-body parsing: the HTTP status still identifies the + // failure, so malformed gateway JSON must not mask it. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, httpErrorCode(response.status, providerError)) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 695831b1bd..f9f223b6ff 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -11,12 +11,9 @@ import type {} from '@deepseek-ai/dsh-llm' import { DeepSeekAdapter } from './adapter.ts' import type { DeepSeekCatalogModel } from './adapter.ts' -export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' +export { DeepSeekAdapter } from './adapter.ts' export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts' -export { serializeMessages, serializeRequest } from './serialize.ts' export type { RequestDefaults } from './serialize.ts' -export { DONE, parseSse } from './sse.ts' -export { mapFinishReason, mapUsage, translate } from './translate.ts' export type * from './types.ts' export const name = 'llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index c0a2f8f482..5d15a1e437 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,9 +2,10 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ @@ -159,7 +160,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior, behavior]) + const server = await mockServer([behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -167,11 +168,32 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) - // The numeric HTTP status is carried on the error for explicit handling. - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).status), - ).resolves.toBe(status) + }) + + it('classifies a thrown HTTP context-window rejection with the canonical code', async () => { + const server = await mockServer([{ + kind: 'http-error', + status: 400, + body: JSON.stringify({ + error: { + message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.', + type: 'invalid_request_error', + code: 'context_length_exceeded', + }, + }), + }]) + const ctx = await harness(server.url) + const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + .catch((error: unknown) => (error as LlmError).code) + expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + }) + + it('classifies only context-capacity HTTP 400 details as context overflow', () => { + expect(httpErrorCode(400, { message: 'request too large for model context' })) + .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) + .toBe('INVALID_REQUEST') + expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') }) it('keeps the status-line message for JSON error bodies without a message', async () => { @@ -241,6 +263,19 @@ describe('DeepSeekAdapter against a mock server', () => { }) describe('plugin registration and config', () => { + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => { const server = await mockServer([]) const ctx = new Context() diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 0a5d10e23b..8d3b190ed1 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' +import { serializeMessages, serializeRequest } from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], ...overrides } diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 2fc297bbec..b18862e4f3 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE, parseSse } from '../src/sse.ts' /** Build a byte stream from string fragments (fragments = network reads). */ async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index d6968faed5..e62cebc4af 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE } from '../src/sse.ts' +import { mapFinishReason, mapUsage, translate } from '../src/translate.ts' async function* feed(...payloads: (string | object)[]): AsyncGenerator { for (const payload of payloads) { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bb63d82010..06395d701c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,6 +2,8 @@ Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog. +The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal. + ## Config Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported. @@ -41,7 +43,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state ## Vocabulary differences - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. -- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks. +- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks. Provider-specific error text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. @@ -61,19 +63,35 @@ Unit tests use pi-ai catalog models redirected to local mock servers and cover p ### Provider request through pi-ai -**What the model sees**: The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +#### What the model sees -**Token effect**: Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content. + +#### Token effect + +Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state. + +#### KV Cache effect + +Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. ### Provider response -**What the model sees**: pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. +#### What the model sees -**Token effect**: Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately. +pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings. + +#### Token effect + +Generated content affects later inputs only after the loop records it. pi-ai folds reasoning tokens into output usage when the provider does not report them separately. + +#### KV Cache effect + +Recorded response content appends to the next request and does not invalidate its earlier reusable prefix. Unrecorded transport metadata and usage accounting do not affect cache identity. ## Known Limitations and Deferred Work - **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field. - **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override. -- **`LlmError.status` is unavailable for in-stream failures** — pi-ai error events do not expose a stable HTTP status across providers. +- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 26e28ecd1f..7f40c67da3 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -115,7 +115,7 @@ export class PiAiAdapter extends LlmAdapter { // Harness-owned and therefore win collisions. headers: requestHeaders(profile.headers), }) - yield* toStreamChunks(events) + yield* toStreamChunks(events, model.contextWindow) } finally { options.signal?.removeEventListener('abort', onCallerAbort) controller.abort('consumer stopped streaming') diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index cbd0dcd435..ab08f21b81 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -27,12 +27,8 @@ import { Config, resolveProfiles } from './config.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' -export { Config, resolveProfiles } from './config.ts' +export { Config } from './config.ts' export type { PiAiProviderProfile } from './config.ts' -export { toPiContext } from './context.ts' -export { toPiReplayState } from './replay.ts' -export type { PiAiReplayState } from './replay.ts' -export { mapStopReason, mapUsage, toStreamChunks } from './stream.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 5bdce3c528..c1a85addf0 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -8,8 +8,9 @@ * @module dsh-llm-pi-ai/stream */ -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm' import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm' +import { isContextOverflow } from '@earendil-works/pi-ai' import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai' import { toPiReplayState } from './replay.ts' @@ -38,9 +39,24 @@ function classifyPiAiError(message: string): string { /** * Map a terminal pi-ai event to the harness finish reason. * @param message - the assistant message carried by the `done` or `error` event. - * @returns the harness reason; `error` yields `{kind: 'error'}` with a code classified from the error text. + * @param contextWindow - resolved catalog capacity for usage-based overflow detection. + * @returns the mapped harness reason. Recognized error text, `stop` usage above + * `contextWindow`, and zero-output `length` usage that fills the window map + * to `CONTEXT_WINDOW_EXCEEDED`. */ -export function mapStopReason(message: AssistantMessage): FinishReason { +export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason { + const piAiOverflow = isContextOverflow(message, contextWindow) + const harnessOverflow = message.stopReason === 'error' + && message.errorMessage !== undefined + && isContextWindowExceededError(message.errorMessage) + if (piAiOverflow || harnessOverflow) { + return { + kind: 'error', + message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + } + } + switch (message.stopReason) { case 'stop': return { kind: 'stop' } case 'length': return { kind: 'max-tokens' } @@ -58,10 +74,14 @@ export function mapStopReason(message: AssistantMessage): FinishReason { * mid-stream — failures arrive as `error` events, which become error/aborted * `finish` chunks (the harness protocol's other error-delivery style). * @param events - one assistant turn's pi-ai event stream. + * @param contextWindow - resolved catalog capacity for usage-based overflow detection. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event. */ -export async function* toStreamChunks(events: AsyncIterable): AsyncGenerator { +export async function* toStreamChunks( + events: AsyncIterable, + contextWindow?: number, +): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 // in stream order), but we track ids per index for tool calls. const toolIds = new Map() @@ -124,13 +144,17 @@ export async function* toStreamChunks(events: AsyncIterable { const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) expect(result.finish).toMatchObject({ kind: 'error', code }) }) + + it('uses the resolved catalog context window for usage-based overflow detection', async () => { + const model = getModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash') + if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog') + const events = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + JSON.stringify({ + choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], + usage: { prompt_tokens: model.contextWindow + 1, completion_tokens: 0 }, + }), + '[DONE]', + ] + const server = await mockServer([{ events }]) + const ctx = await harness(server.url) + + const result = await assemble(ctx, { model: model.id, messages: [] }) + + expect(result.finish).toEqual({ + kind: 'error', + message: `pi-ai detected context overflow for model "${model.id}"`, + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + }) }) describe('provider profile lifecycle', () => { + it('keeps adapter helpers off the package root', () => { + for (const helper of [ + 'resolveProfiles', + 'toPiContext', + 'toPiReplayState', + 'toPiAssistant', + 'mapStopReason', + 'mapUsage', + 'toStreamChunks', + ]) expect(LlmPiAi).not.toHaveProperty(helper) + }) + it('registers every profile atomically and unregisters on dispose', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 50955f0607..a2f37ce511 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' -import { mapStopReason, mapUsage, toPiContext, toPiReplayState, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' +import { toPiContext } from '../src/context.ts' +import { toPiReplayState } from '../src/replay.ts' +import { mapStopReason, mapUsage, toStreamChunks } from '../src/stream.ts' function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage { return { @@ -521,6 +523,46 @@ describe('mapStopReason / mapUsage', () => { .toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' }))) .toMatchObject({ kind: 'error', code: 'SERVER' }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: input exceeds the model context window limit', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: request too large for model context', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value', + }))).toMatchObject({ kind: 'error', code: 'INVALID_REQUEST' }) + }) + + it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => { + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'prompt is too long: 213462 tokens > 200000 maximum', + }))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE }) + expect(mapStopReason(assistant({ + stopReason: 'error', + errorMessage: 'ThrottlingException: Too many tokens, rate limit reached', + }))).toMatchObject({ kind: 'error', code: 'RATE_LIMIT' }) + }) + + it('uses the resolved context window for silent and length-stop overflows', () => { + const silent = assistant({ stopReason: 'stop', usage: usage(101, 0) }) + expect(mapStopReason(silent)).toEqual({ kind: 'stop' }) + expect(mapStopReason(silent, 100)).toEqual({ + kind: 'error', + message: 'pi-ai detected context overflow for model "deepseek-v4-flash"', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) + + const truncated = assistant({ stopReason: 'length', usage: usage(80, 0, 19) }) + expect(mapStopReason(truncated)).toEqual({ kind: 'max-tokens' }) + expect(mapStopReason(truncated, 100)).toMatchObject({ + kind: 'error', + code: CONTEXT_WINDOW_EXCEEDED_CODE, + }) }) it('maps cache fields only when nonzero', () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index a67859ed19..24a8879aa3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -13,6 +13,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. +`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. + Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. ### Events @@ -45,7 +47,8 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail. ### Real adapters @@ -55,9 +58,13 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message. +#### KV Cache effect + +Pass-through; the registry preserves the assembled request prefix, while the selected adapter and provider own cache reuse and routing boundaries. + ## Known Limitations and Deferred Work -- **No retry/caching/rate-limit layer ships** — `llm/stream` is the intended wrap seam and has no production listener, so provider 429/5xx failures surface immediately. +- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. - **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). - **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). - **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. diff --git a/packages/llm/llm/src/adapter-failure.ts b/packages/llm/llm/src/adapter-failure.ts new file mode 100644 index 0000000000..745cbbdc64 --- /dev/null +++ b/packages/llm/llm/src/adapter-failure.ts @@ -0,0 +1,67 @@ +/** + * Private provider-failure tagging shared by `LlmService` and its consumers. + * + * @module @deepseek-ai/dsh-llm/adapter-failure + */ + +import { HarnessError } from './error.ts' +import type { StreamChunk } from './types.ts' + +/** Errors proven to originate in one model call's final adapter boundary. */ +export type AdapterFailureScope = WeakSet + +/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */ +const adapterFailureScopes = new WeakMap, AdapterFailureScope>() + +/** + * Bind one call's adapter-failure scope to a unique returned stream handle. + * @param stream - the waterfall-selected stream for this call. + * @param failures - errors tagged by this call's final adapter boundary. + * @returns a unique stream handle that delegates iteration to `stream`. + * @internal + */ +export function bindAdapterFailureScope( + stream: AsyncIterable, + failures: AdapterFailureScope, +): AsyncIterable { + const call = { + [Symbol.asyncIterator](): AsyncIterator { + return stream[Symbol.asyncIterator]() + }, + } + adapterFailureScopes.set(call, failures) + return call +} + +/** + * Preserve an adapter's Error identity while tagging its provider origin. + * @param failures - the call-local final-adapter failure scope. + * @param value - arbitrary value thrown by adapter dispatch or iteration. + * @returns the original Error, or a coded Error wrapping a non-Error throw. + * @internal + */ +export function markLlmAdapterFailure( + failures: AdapterFailureScope, + value: unknown, +): Error & { code?: string } { + const error = value instanceof Error + ? value as Error & { code?: string } + : new HarnessError(String(value), 'UNKNOWN', { cause: value }) + failures.add(error) + return error +} + +/** + * Whether a failure came from final adapter dispatch, iterator construction, + * or iteration for the call represented by the exact returned stream handle. + * @param stream - the exact stream returned by the model call being classified. + * @param value - arbitrary failure caught by a model-call consumer. + * @returns true only for errors tagged at that call's final adapter boundary. + */ +export function isLlmAdapterFailure( + stream: AsyncIterable, + value: unknown, +): value is Error & { code?: string } { + const failures = adapterFailureScopes.get(stream) + return value instanceof Error && failures !== undefined && failures.has(value) +} diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 8780718c7a..a721721fb6 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -39,12 +39,10 @@ export class BlockAssembler { private _replayState: unknown = undefined /** - * Feed one chunk. Returns the completed block when the chunk closes one - * (an explicit `block-end`), otherwise undefined. + * Feed one chunk into the assembly state. * @param chunk - the next raw chunk, in stream order. - * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk. */ - push(chunk: StreamChunk): ContentBlock | undefined { + push(chunk: StreamChunk): void { switch (chunk.type) { case 'block-start': { if (!this.partials.has(chunk.index)) { @@ -78,7 +76,7 @@ export class BlockAssembler { // and the final assembled block in agreement. if (partial.block) return partial.block = chunk.block - return chunk.block + return } case 'usage': { this._usage = chunk.usage diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index c1fdbb9ffa..8c1c736492 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -21,6 +21,47 @@ export class HarnessError extends Error { } } +/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */ +export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED' + +/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ +const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( + String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, + 'i', +) + +/** Request-size wording that ties "too large" directly to model context capacity. */ +const TOO_LARGE_FOR_CONTEXT = new RegExp( + String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, + 'i', +) + +/** "Exceeds" wording is safe only when its object is explicitly the model context. */ +const EXCEEDS_MODEL_CONTEXT = new RegExp( + String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, + 'i', +) + +/** + * Recognize the context-overflow wording used by OpenAI-compatible providers + * and library adapters. Adapters pass all available provider code, type, and + * message text so both thrown and in-band delivery styles share one classifier. + * @param detail - provider error code/type/message text joined into one string. + * @returns true when the detail identifies a request exceeding the model context window. + */ +export function isContextWindowExceededError(detail: string): boolean { + return STRUCTURED_CONTEXT_OVERFLOW.test(detail) + || /\b(?:maximum|max)(?:\s+(?:allowed|supported))?\s+context\s+(?:length|window)\b/i.test(detail) + || TOO_LARGE_FOR_CONTEXT.test(detail) + || /\b(?:input|prompt|request)\s+(?:is\s+)?too\s+(?:long|large)\s+for\s+(?:this|the)\s+model\b/i.test(detail) + || EXCEEDS_MODEL_CONTEXT.test(detail) +} + /** * Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at seams). * @param value - the caught value (`unknown` in catch clauses). diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 26e9e3b289..ac31738a61 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -8,8 +8,10 @@ import { Context, Service } from 'cordis' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' -import { HarnessError } from './error.ts' import { deepFreeze } from './call-config.ts' +import { HarnessError } from './error.ts' +import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' +import type { AdapterFailureScope } from './adapter-failure.ts' export * from './attribution.ts' export * from './brand.ts' @@ -19,6 +21,7 @@ export * from './types.ts' export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze } from './call-config.ts' export type { LlmCallConfig } from './call-config.ts' +export { isLlmAdapterFailure } from './adapter-failure.ts' declare module 'cordis' { interface Context { @@ -43,12 +46,10 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; - * `status` carries the HTTP status when the error originated from a non-2xx - * provider response (absent for protocol/usage errors that have no HTTP status). + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { + constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } @@ -198,20 +199,72 @@ export class LlmService extends Service { return Object.isFrozen(options) ? deepFreeze(filtered) : filtered } + /** + * Final adapter boundary. It tags only failures from adapter selection, + * synchronous dispatch, iterator construction, or iteration while preserving + * the original Error object. Middleware outside this generator remains + * distinguishable as plugin work. An iteration failure skips adapter cleanup + * so it cannot suppress the primary provider error. A downstream close awaits + * adapter cleanup, whose failures remain ordinary untagged work. + */ + private async * adapterStream( + options: GenerateOptions, + failures: AdapterFailureScope, + ): AsyncGenerator { + let iterator: AsyncIterator + try { + const adapter = this.registration(options.provider).adapter + const stream = adapter.stream(this.forAdapter(options, adapter)) + iterator = stream[Symbol.asyncIterator]() + } catch (error: unknown) { + throw markLlmAdapterFailure(failures, error) + } + + let completed = false + let iterationFailed = false + try { + while (true) { + let value: StreamChunk + try { + const item = await iterator.next() + if (item.done) { + completed = true + return + } + value = item.value + } catch (error: unknown) { + iterationFailed = true + throw markLlmAdapterFailure(failures, error) + } + // End the adapter-owned try before yielding: consumer/middleware + // failures resumed into this generator must remain untagged. + yield value + } + } finally { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the iteration catch sets its latch before entering finally. + if (!completed && !iterationFailed) { + const close = iterator.return?.bind(iterator) + if (close) await close() + } + } + } + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.provider`. Replay state is retained only when the same adapter - * instance owns its historical provider and the target provider. Dispatches - * through the `llm/stream` waterfall. + * instance owns its historical provider and the target provider. Final + * adapter selection, dispatch, and iteration failures retain their original + * Error identity and are tagged in a call-local scope for narrow agent-loop + * request recovery; middleware and nested-call failures remain untagged for + * the outer call. * @param options - the full request; `options.provider` selects the adapter. * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { - return this.ctx.waterfall(this, 'llm/stream', options, () => { - const adapter = this.registration(options.provider).adapter - return adapter.stream(this.forAdapter(options, adapter)) - }) + const failures: AdapterFailureScope = new WeakSet() + const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures)) + return bindAdapterFailureScope(stream, failures) } } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 5612e93cb4..f7a5028d14 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -29,12 +29,12 @@ describe('BlockAssembler', () => { expect(assembler.message().role).toBe('assistant') }) - it('returns the completed block from push() on block-end', () => { + it('records the completed block from block-end', () => { const assembler = new BlockAssembler() - expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined() - expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined() - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('tolerates deltas without explicit block-start/end', () => { @@ -57,8 +57,8 @@ describe('BlockAssembler', () => { // push a delta first to guarantee the partial exists assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) // block-end's ensure() must find the existing partial (the second branch path) - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('throws from assemble() when a partial has an unhandled blockType', () => { @@ -128,7 +128,7 @@ describe('assertNever', () => { it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => { const assembler = new BlockAssembler() - expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk)) + expect(() => { assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk) }) .toThrow('unreachable variant in BlockAssembler.push') }) }) @@ -140,26 +140,8 @@ describe('BlockAssembler duplicate-close contract', () => { { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, ] - const streaming = new BlockAssembler() - const closed = [] - for (const chunk of chunks) { - const block = streaming.push(chunk) - if (block) closed.push(block) - } - - const oneShot = new BlockAssembler() - for (const chunk of chunks) oneShot.push(chunk) - - expect(closed).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(closed).toEqual(oneShot.blocks()) - }) - - it('push returns undefined for a duplicate block-end (it closed nothing)', () => { - const a = new BlockAssembler() - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } })) - .toEqual({ type: 'text', text: 'x' }) - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } })) - .toBeUndefined() + const assembler = new BlockAssembler() + for (const chunk of chunks) assembler.push(chunk) + expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 83429ba0cd..6e14d749ba 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { + GenerateOptions, + HarnessError, + isContextWindowExceededError, + isLlmAdapterFailure, + LlmAdapter, + LlmError, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { @@ -22,6 +30,16 @@ class RecordingAdapter extends ScriptedAdapter { } } +class ThrowingAdapter extends LlmAdapter { + constructor(private readonly failure: Error) { + super() + } + + stream(_options: GenerateOptions): AsyncIterable { + throw this.failure + } +} + class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, @@ -46,6 +64,22 @@ const SCRIPT: StreamChunk[] = [ ] describe('LlmService', () => { + it('recognizes structured and model-capacity context-window overflow details', () => { + expect(isContextWindowExceededError('context_length_exceeded maximum context length')).toBe(true) + expect(isContextWindowExceededError('context-window-overflowed')).toBe(true) + expect(isContextWindowExceededError('This model maximum context length is 128000 tokens')).toBe(true) + expect(isContextWindowExceededError('input is too long for this model')).toBe(true) + expect(isContextWindowExceededError('request too large for model context')).toBe(true) + expect(isContextWindowExceededError('input exceeds the model context window limit')).toBe(true) + }) + + it('does not mistake unrelated input validation for context-window overflow', () => { + expect(isContextWindowExceededError('invalid request: malformed tool arguments')).toBe(false) + expect(isContextWindowExceededError('invalid input: temperature exceeds maximum allowed value')).toBe(false) + expect(isContextWindowExceededError('input exceeds maximum allowed value')).toBe(false) + expect(isContextWindowExceededError('context window size must be positive')).toBe(false) + }) + it('routes stream() to the registered adapter', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -59,9 +93,308 @@ describe('LlmService', () => { it('throws NO_ADAPTER for unregistered providers', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await expect((async () => { - for await (const _ of ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] })) { /* drain */ } - })()).rejects.toThrow('no adapter registered') + const stream = ctx.llm.stream({ provider: 'nope', model: 'any-model', messages: [] }) + let caught: unknown + try { + for await (const _ of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + expect(caught).toBeInstanceOf(LlmError) + expect((caught as LlmError).code).toBe('NO_ADAPTER') + expect((caught as LlmError).message).toContain('no adapter registered') + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + }) + + it.each(['done', 'value'] as const)('tags a throwing IteratorResult.%s getter without replacing its Error', async (field) => { + const original = new LlmError(`${field} getter failed`, 'RESULT_GETTER_FAILED') + const result = field === 'done' ? {} : { done: false } + Object.defineProperty(result, field, { get: () => { throw original } }) + let cleanupLookups = 0 + const iterator: AsyncIterator = { + next: () => Promise.resolve(result as unknown as IteratorResult), + } + Object.defineProperty(iterator, 'return', { + get: () => { + cleanupLookups += 1 + throw new Error('return getter must not run after iteration fails') + }, + }) + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return iterator + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(cleanupLookups).toBe(0) + }) + + it.each(['dispatch', 'iterator'] as const)('tags synchronous adapter %s failures without replacing their Error', async (boundary) => { + const original = new LlmError(`${boundary} failed`, 'BOUNDARY_FAILED') + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + if (boundary === 'dispatch') throw original + return { [Symbol.asyncIterator]: () => { throw original } } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + }) + + it('keeps a nested adapter failure scoped to the nested model call', async () => { + const original = new LlmError('nested provider failed', 'NESTED_FAILED') + const outer = new RecordingAdapter(SCRIPT) + const nested = new ThrowingAdapter(original) + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['outer'], outer) + ctx.llm.registerAdapter(['nested'], nested) + let nestedStream: AsyncIterable | undefined + ctx.on('llm/stream', (options, next) => { + if (options.provider !== 'outer') return next() + return (async function* () { + nestedStream = ctx.llm.stream({ provider: 'nested', model: 'nested', messages: [] }) + yield * nestedStream + })() + }) + + const outerStream = ctx.llm.stream({ provider: 'outer', model: 'outer', messages: [] }) + let caught: unknown + try { + for await (const _chunk of outerStream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(original) + expect(nestedStream).toBeDefined() + expect(isLlmAdapterFailure(nestedStream!, caught)).toBe(true) + expect(isLlmAdapterFailure(outerStream, caught)).toBe(false) + expect(outer.lastOptions).toBeUndefined() + }) + + it('keeps call scopes distinct when middleware reuses an iterable', async () => { + const firstFailure = new LlmError('first provider failed', 'FIRST_FAILED') + const secondFailure = new LlmError('second provider failed', 'SECOND_FAILED') + const delegates: AsyncIterable[] = [] + const shared: AsyncIterable = { + [Symbol.asyncIterator](): AsyncIterator { + const delegate = delegates.shift() + if (delegate === undefined) throw new Error('shared stream has no call delegate') + return delegate[Symbol.asyncIterator]() + }, + } + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['first'], new ThrowingAdapter(firstFailure)) + ctx.llm.registerAdapter(['second'], new ThrowingAdapter(secondFailure)) + ctx.on('llm/stream', (_options, next) => { + delegates.push(next()) + return shared + }) + + const firstStream = ctx.llm.stream({ provider: 'first', model: 'first', messages: [] }) + const secondStream = ctx.llm.stream({ provider: 'second', model: 'second', messages: [] }) + const catchFailure = async (stream: AsyncIterable): Promise => { + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + return error + } + return new Error('expected adapter to fail') + } + + expect(firstStream).not.toBe(secondStream) + const firstCaught = await catchFailure(firstStream) + expect(firstCaught).toBe(firstFailure) + expect(isLlmAdapterFailure(firstStream, firstCaught)).toBe(true) + expect(isLlmAdapterFailure(secondStream, firstCaught)).toBe(false) + const secondCaught = await catchFailure(secondStream) + expect(secondCaught).toBe(secondFailure) + expect(isLlmAdapterFailure(secondStream, secondCaught)).toBe(true) + expect(isLlmAdapterFailure(firstStream, secondCaught)).toBe(false) + expect(delegates).toHaveLength(0) + }) + + it('propagates a rejected next promptly without awaiting a non-settling return', async () => { + const original = new LlmError('provider failed', 'PROVIDER_FAILED') + let cleanupCalls = 0 + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => Promise.reject(original), + return: () => { + cleanupCalls += 1 + return new Promise>(() => {}) + }, + } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + const failure = (async (): Promise => { + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + return error + } + return new Error('expected adapter iteration to fail') + })() + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => { resolve(new Error('adapter failure did not settle promptly')) }, 100) + }) + const caught = await Promise.race([failure, timeout]) + if (timer !== undefined) clearTimeout(timer) + + expect(caught).toBe(original) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + expect(cleanupCalls).toBe(0) + }) + + it('awaits one adapter return on downstream close and leaves its rejection unclassified', async () => { + const cleanup = new Error('cleanup failed') + let cleanupCalls = 0 + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => Promise.resolve({ done: false, value: SCRIPT[0]! }), + return: () => { + cleanupCalls += 1 + return Promise.reject(cleanup) + }, + } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) break + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(cleanup) + expect(isLlmAdapterFailure(stream, caught)).toBe(false) + expect(cleanupCalls).toBe(1) + }) + + it('allows downstream close when the adapter iterator has no return method', async () => { + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.resolve({ done: false, value: SCRIPT[0]! }) } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + let chunks = 0 + for await (const _chunk of ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] })) { + chunks += 1 + break + } + + expect(chunks).toBe(1) + }) + + it('normalizes and tags non-Error adapter failures once', async () => { + const adapter = new class extends LlmAdapter { + stream(_options: GenerateOptions): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + // Third-party adapters can reject with arbitrary values. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return { next: () => Promise.reject('plain provider failure') } + }, + } + } + }() + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], adapter) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) { /* drain */ } + } catch (error: unknown) { + caught = error + } + + expect(caught).toBeInstanceOf(HarnessError) + expect(caught).toMatchObject({ code: 'UNKNOWN', cause: 'plain provider failure' }) + expect(isLlmAdapterFailure(stream, caught)).toBe(true) + }) + + it('does not tag a failure thrown downstream while consuming adapter output', async () => { + const downstream = new Error('consumer failed') + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['test-model'], new ScriptedAdapter(SCRIPT)) + + const stream = ctx.llm.stream({ provider: 'test-model', model: 'test-model', messages: [] }) + let caught: unknown + try { + for await (const _chunk of stream) throw downstream + } catch (error: unknown) { + caught = error + } + + expect(caught).toBe(downstream) + expect(isLlmAdapterFailure(stream, caught)).toBe(false) + expect(isLlmAdapterFailure(new ScriptedAdapter(SCRIPT).stream({ + provider: 'unbound', model: 'unbound', messages: [], + }), caught)).toBe(false) + expect(isLlmAdapterFailure(stream, 'consumer failed')).toBe(false) }) it('unregisters adapters when the owning fiber is disposed (HMR safety)', async () => { @@ -255,11 +588,12 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const err = new LlmError('boom', 'AUTH', 401) + const cause = new Error('root cause') + const err = new LlmError('boom', 'AUTH', { cause }) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.status).toBe(401) + expect(err.cause).toBe(cause) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 262fc8d152..20539de6a4 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -42,6 +42,10 @@ Both plugins have usable defaults. A deployment with a different capacity config Indirectly, through consumers such as `dsh-compact-basic`; the service itself adds no prompt, message, schema, tool, or model call. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The fixed heuristic is approximate** — content without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer. diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index e8c4f54f5f..ebcf29fad5 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -70,15 +70,31 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` ### Discovered MCP tools -**What the model sees**: After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it. +#### What the model sees -**Token effect**: Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call. +After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it. + +#### Token effect + +Data-dependent schema cost is paid on every request while the tools are registered. Re-sync replaces rather than accumulates schemas, and the server-qualified name adds tokens to every tool definition and call. + +#### KV Cache effect + +Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token. ### Tool-call history and results -**What the model sees**: The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. +#### What the model sees -**Token effect**: Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context. +The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained text result; image, audio, resource, and unsupported blocks become short placeholders, and MCP `isError` results follow the registry's model-visible error path. + +#### Token effect + +Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index cbd765bbf9..7df7cc2e31 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -25,6 +25,10 @@ Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred. diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index 93e274b485..087786c802 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -14,16 +14,24 @@ Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: ` ### Confinement error, indirectly -**What the model sees**: Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: `. +#### What the model sees -**Token effect**: Conditional error text is visible for that call and retained in history until compaction. +Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: `. -#### Exact error +##### Exact error ```markdown sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. ``` +#### Token effect + +Conditional error text is visible for that call and retained in history until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ## Known Limitations and Deferred Work - **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions. diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index a5fa51b480..c0d1f5d026 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -14,6 +14,10 @@ The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. Deep Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none. diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 362e5046f9..c7c74c9659 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -309,6 +309,7 @@ describe('CreateWizard and scaffolder', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('ctx.agents.create') expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }') + expect(index).not.toContain('AgentId') const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8')) const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8')) expect(tsconfig.compilerOptions.types).toEqual(['node']) diff --git a/packages/sdk/helper/README.md b/packages/sdk/helper/README.md index 5b5608cdf0..31ee34f59e 100644 --- a/packages/sdk/helper/README.md +++ b/packages/sdk/helper/README.md @@ -18,6 +18,10 @@ The package root explicitly exports only the objects consumed by `create-sdk` an None, as the project domain edits files and never mounts a live agent or model request. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written. diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index a89f2c84ae..e4ff11af20 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-helper/features/builtin/app */ +import { JsExpression } from '../../documents/cordis-yaml-file.ts' import { featureId } from '../../ids.ts' import type { ProjectProfile } from '../../project/types.ts' import { @@ -94,11 +95,11 @@ class AppOption extends FeatureOption { name: '@deepseek-ai/dsh-stdio', config: { welcome: 'agent REPL ready. Give it a coding task.', - agent: 'main', + sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'), }, - }, ['welcome', 'agent'], config => [ + }, ['welcome', 'sessionId'], config => [ ...optionalString(config, 'welcome'), - ...requiredString(config, 'agent'), + ...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'), ]), ]) case 'embed': diff --git a/packages/sdk/helper/src/project/npm-dependency-policy.ts b/packages/sdk/helper/src/project/npm-dependency-policy.ts index a8877826e2..727bd6648a 100644 --- a/packages/sdk/helper/src/project/npm-dependency-policy.ts +++ b/packages/sdk/helper/src/project/npm-dependency-policy.ts @@ -23,7 +23,7 @@ const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly> = { '@cordisjs/plugin-timer': '^1.1.2', '@types/node': '^22.20.0', cordis: '^4.0.0-rc.7', - tsdown: '^0.22.2', + tsdown: '0.22.2', tsx: '^4.22.4', typescript: '^6.0.3', } diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index d0315db80c..a79818908c 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -2,14 +2,12 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{else}} import { randomUUID } from 'node:crypto' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{/if}} /** Boot this project's cordis.yml when invoked by dsh-scripts. */ export async function main(boot: SdkBootContext) { - const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) {{#if isStdio}} const model = boot.args.model if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=') @@ -17,24 +15,35 @@ export async function main(boot: SdkBootContext) { if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { throw new Error('stdio startup requires --resume=') } - if (resume === undefined) { - await ctx.agents.create({ - agentId: AgentId('main'), - sessionId: SessionId(`main-session-${randomUUID()}`), - meta: { cwd: boot.cwd }, - agentOptions: { model }, - }) - } else { - await ctx.agents.resume({ - agentId: AgentId('main'), - resumeSessionId: SessionId(resume), - agentOptions: { model }, - }) + const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`) + process.env.DSH_SDK_SESSION_ID = sessionId +{{/if}} + const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) +{{#if isStdio}} + try { + if (resume === undefined) { + await ctx.agents.create({ + sessionId, + meta: { cwd: boot.cwd }, + agentOptions: { model }, + }) + } else { + await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { model }, + }) + } + } catch (error) { + try { + await ctx.fiber.dispose() + } catch (disposeError) { + throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed') + } + throw error } {{else}} {{#if isEmbed}} await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId(`main-session-${randomUUID()}`), meta: { cwd: boot.cwd }, agentOptions: { model: {{modelLiteral}} }, diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 469887036a..7181820725 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -283,6 +283,7 @@ describe('package manager strategies', () => { section: 'devDependencies', spec: '^4.0.0-rc.7', }) expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') + expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') }) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 6a6268b554..80bd578e69 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -167,6 +167,12 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('agents.create') expect(index).toContain('boot.args.resume') + expect(index).not.toContain('AgentId') + expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)') + expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') + expect(index).toContain('resumeSessionId: sessionId') + expect(index).toContain('await ctx.fiber.dispose()') + expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')") expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', @@ -175,7 +181,11 @@ describe('SdkProject and ProjectEditSession', () => { config: 'dsh-sdk config', }) expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('stdio')?.config).toMatchObject({ agent: 'main' }) + expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({ + source: 'process.env.DSH_SDK_SESSION_ID', + }) + expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) + .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') @@ -286,7 +296,10 @@ describe('SdkProject and ProjectEditSession', () => { const embed = (await embedEdit.commit()).project expect(embed.profile.runInterface).toBe('embed') expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness') - expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create') + const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8') + expect(embedIndex).toContain('agents.create') + expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'") + expect(embedIndex).not.toContain('AgentId') await writeFile(join(embed.root, 'README.md'), '# Custom README\n') const modified = await SdkProject.open(embed.root) @@ -884,6 +897,12 @@ describe('extension points', () => { resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') expect(acpEntry?.entry.id).toBe('acp') expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) + const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources + .find((resource): resource is CordisConfigEntryResource => + resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio') + expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ + 'sessionId must be a non-empty string', + ]) const embedOption = app.options.find(option => option.id === 'embed') expect(embedOption?.markerConfigEntries(profile)).toEqual([]) expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([ diff --git a/packages/sdk/scripts/README.md b/packages/sdk/scripts/README.md index 39d706177e..13c46b47ad 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -26,6 +26,10 @@ The root library exports `startSDK`, `runSDK`, and the `SdkBootArgs`/`SdkBootCon Indirectly, through the project `cordis.yml` tree loaded by `start` or `dev`. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Launcher arguments are schema-free** — `start` and `dev` preserve Node `parseArgs()` output rather than validating project-specific flags. diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 43f4443107..ed7a056c69 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,7 +23,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics -- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. +- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — preserve valid tail work.** `load` keeps the contiguous valid prefix of an interrupted final turn. It truncates from the first unparsable or sequence-gapped uncommitted record, then appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md); the same defect at or before the last committed `turn/end` rejects. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. @@ -36,9 +36,17 @@ The plugin buffers frozen session events and drains them on flush or disposal. A ### Resumed conversation history -**What the model sees**: JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages. +#### What the model sees -**Token effect**: Zero live-request tokens. A resumed agent pays for retained history and its current envelope, plus the quoted repair result for each interrupted call. +JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages. + +#### Token effect + +Zero live-request tokens. A resumed agent pays for retained history and its current envelope, plus the quoted repair result for each interrupted call. + +#### KV Cache effect + +JSONL storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append. ## Known Limitations and Deferred Work @@ -46,3 +54,4 @@ The plugin buffers frozen session events and drains them on flush or disposal. A - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). - **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. - **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. +- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 0c8a2ee3ef..4e52cb0b9e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -57,6 +57,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi private root: string private coordinator: PersistenceCoordinator + /** Runtime host platform used to decide whether directory sync is supported. */ + readonly internals: { platform: NodeJS.Platform } = { platform: process.platform } + constructor(ctx: Context, public config: Config) { super(ctx) // Resolve once so later process.cwd() changes cannot split one backend across roots. @@ -208,11 +211,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** fsync a directory so a just-created or published entry inside it is crash-durable. */ + /** fsync a directory when the host exposes that durability primitive. */ private async syncDir(dir: string): Promise { const handle = await open(dir, 'r') try { - await handle.sync() + try { + await handle.sync() + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | null)?.code + // Node opens directories on Windows but its fsync binding rejects them. + // File-content fsync remains mandatory; only this unsupported primitive is skipped. + if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error + } } finally { await handle.close() } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 4b279e3c6e..e7dc469132 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -40,9 +41,25 @@ async function freshRoot(): Promise { } afterEach(async () => { + vi.restoreAllMocks() for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +async function rejectDirectorySync(code: string): Promise { + const handle = await open(root, 'r') + const proto = Object.getPrototypeOf(handle) as { sync: () => Promise } + await handle.close() + const realSync = proto.sync + vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) { + if ((await this.stat()).isDirectory()) { + const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException + error.code = code + throw error + } + return realSync.call(this) + }) +} + function appendClosedTurn(session: Session): void { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { @@ -336,6 +353,28 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) }) + it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => { + await rejectDirectorySync('EPERM') + const backend = ctx.sessionPersistence as SessionPersistenceJsonl + backend.internals.platform = 'win32' + const m = meta('windows-directory-sync') + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined() + expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog()) + }) + + it.each([ + ['linux', 'EPERM'], + ['win32', 'EIO'], + ] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => { + await rejectDirectorySync(code) + const backend = ctx.sessionPersistence as SessionPersistenceJsonl + backend.internals.platform = platform + const m = meta(`directory-sync-${platform}-${code}`) + await ctx.sessionPersistence.create(m) + await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code }) + }) + it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => { const m = meta('meta-copy', '/proj') await ctx.sessionPersistence.create(m) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 241e7ceb38..5eb0338f99 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -12,6 +12,8 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. + ## Contract semantics over rows - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) @@ -35,9 +37,17 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer ### Resumed conversation history -**What the model sees**: SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages. +#### What the model sees -**Token effect**: Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call. +SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages. + +#### Token effect + +Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call. + +#### KV Cache effect + +SQLite storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append. ## Known Limitations and Deferred Work diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index d886124961..4661b41309 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -9,7 +9,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { DatabaseSync } from 'node:sqlite' -import { mkdir } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, PersistenceCoordinator, @@ -35,12 +35,32 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } +/** + * Exclusively create a missing database file with owner-only permissions. + * Existing files retain their modes, and errors other than `EEXIST` propagate. + * `DatabaseSync` reopens by path, so this does not protect confidentiality or + * integrity when another principal can replace the database entry in its parent + * directory. + */ +async function createDatabaseFile(path: string): Promise { + try { + const handle = await open(path, 'wx', 0o600) + await handle.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } +} + /** Plugin configuration. */ export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** @@ -88,6 +108,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (path !== ':memory:') { const abs = resolve(path) await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) + await createDatabaseFile(abs) this.db = openDatabase(abs, journalMode) } else { this.db = openDatabase(path, journalMode) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a728cc0b71..f26edfa54d 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' @@ -390,6 +390,61 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const dir = dirname(path) + await chmod(dir, 0o755) + + const b = await backend(path) + await b.ctx.sessionPersistence.list() + + expect((await stat(dir)).mode & 0o777).toBe(0o755) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) + await b.dispose() + }) + + it('creates a persistent rollback journal with owner-only mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' }) + const m = meta('persist-permissions') + + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) + await fiber.dispose() + }) + + it('preserves the mode of an existing database file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + await writeFile(path, '', { mode: 0o644 }) + await chmod(path, 0o644) + + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' }) + await ctx.sessionPersistence.list() + + expect((await stat(path)).mode & 0o777).toBe(0o644) + await fiber.dispose() + }) + + it('surfaces an invalid database path during pre-creation', async () => { + const path = await freshDbPath() + const b = await backend(`${path}\0`) + + await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + await b.dispose() + }) + it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 3390b2cc30..a8c478d941 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -57,9 +57,17 @@ Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `ve ### Resumed conversation history -**What the model sees**: This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call. +#### What the model sees -**Token effect**: Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text. +This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call. + +#### Token effect + +Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text. + +#### KV Cache effect + +Persistence does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append without rewriting earlier history. ## Known Limitations and Deferred Work diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 269ba51e3e..1a5ed99949 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -26,6 +26,10 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi None, as this trusted query service returns cloned session records only to its callers and registers no model-facing prompt, schema, tool, or message. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect. diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 8034441272..69abe82ec8 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -40,6 +40,10 @@ Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdow Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Discovery is one level deep** — only `//SKILL.md` and `/.md` are recognized; nested skill trees and package manifests are ignored. diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 8edcd71ef0..21a8791716 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -39,6 +39,10 @@ The registry does not render model guidance or register model-facing tools. [`@d Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index a2c7ff5c40..4d46ad0b75 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -28,11 +28,11 @@ The tool does not call `agent.inject()` in v1. Its result is already recorded as ### Session prefix -**What the model sees**: If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix. +#### What the model sees -**Token effect**: Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. +If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix. -#### Skill catalog template +##### Skill catalog template ```markdown @@ -46,19 +46,35 @@ If the user names a skill, or the task clearly matches a skill's description, ca ``` +#### Token effect + +Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. + +#### KV Cache effect + +Prefix-stable within a loop instance once the session prefix is composed. A new or resumed instance with different providers, skills, descriptions, visibility, or catalog limits may invalidate reuse from the first changed catalog token. + ### Tool schema -**What the model sees**: The model sees the generated [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). +#### What the model sees -**Token effect**: Fixed schema cost per request where the tool is visible. +The model sees the generated [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). + +#### Token effect + +Fixed schema cost per request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while the tool definition and visibility are unchanged. Shadowing, restrictions, or plugin lifecycle changes may invalidate reuse from this schema. ### Tool result -**What the model sees**: A successful call uses the result template and the provider-managed, directory, URL, or opaque resource guidance below. +#### What the model sees -**Token effect**: Loaded instructions are data-dependent tool-result tokens, resent on later steps until compaction; no duplicate `agent.inject()` copy is made. +A successful call uses the result template and the provider-managed, directory, URL, or opaque resource guidance below. -#### Skill result template +##### Skill result template ```markdown @@ -72,39 +88,55 @@ If the user names a skill, or the task clearly matches a skill's description, ca ``` -#### Provider-managed resource guidance +##### Provider-managed resource guidance ```markdown Resources for this skill are managed by provider "". Load referenced resources only as needed. ``` -#### Directory resource guidance +##### Directory resource guidance ```markdown Base directory for this skill: Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. ``` -#### URL resource guidance +##### URL resource guidance ```markdown Base URL for this skill: Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed. ``` -#### Opaque resource guidance +##### Opaque resource guidance ```markdown Resources for this skill: Load referenced resources only as needed. ``` +#### Token effect + +Loaded instructions are data-dependent tool-result tokens, resent on later steps until compaction; no duplicate `agent.inject()` copy is made. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors -**What the model sees**: Invalid or stale selections return exactly `Error: invalid skill name ""`, `Error: skill "" is unknown or no longer available`, or `Error: skill "" is not available for model invocation`. Provider-thrown lookup text is data-dependent and receives the same `Error: ` wrapper. +#### What the model sees -**Token effect**: Only a failing call adds these retained tokens. +Invalid or stale selections return exactly `Error: invalid skill name ""`, `Error: skill "" is unknown or no longer available`, or `Error: skill "" is not available for model invocation`. Provider-thrown lookup text is data-dependent and receives the same `Error: ` wrapper. + +#### Token effect + +Only a failing call adds these retained tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/spill/spill-local/README.md b/packages/spill/spill-local/README.md index 59d23b8a46..07199c5caa 100644 --- a/packages/spill/spill-local/README.md +++ b/packages/spill/spill-local/README.md @@ -22,6 +22,10 @@ Files land at `/session-/​-`: Indirectly, through spill consumers that render the local path and `read`/`grep` retrieval guidance. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Local spill files persist until external cleanup** — the backend has no session-lifecycle deletion or age-based retention policy, because persisted, resumed, and forked sessions may still reference a path. diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index bb20ac9dfc..64625c61ef 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -36,9 +36,17 @@ The policy sees only the FINAL formatted tool result — not a tool's internal r ### Oversized plain-text result -**What the model sees**: Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. +#### What the model sees -**Token effect**: A successful replacement is at most `maxInlineBytes` UTF-8 bytes and remains in history until compaction; the full spill text is not resent to the model. +Results at or below `maxInlineBytes`, `read` results, blocked decisions, and results containing non-text blocks are unchanged. An oversized plain-text result becomes a bounded head/tail preview followed by `(Omitted bytes. Full formatted result stored at: . )`; storage or ownership failures leave the original result visible. + +#### Token effect + +A successful replacement is at most `maxInlineBytes` UTF-8 bytes and remains in history until compaction; the full spill text is not resent to the model. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/spill/spill/README.md b/packages/spill/spill/README.md index c80277339b..c6a26cfddc 100644 --- a/packages/spill/spill/README.md +++ b/packages/spill/spill/README.md @@ -30,6 +30,10 @@ See the [tool output spill RFC](../../../docs/rfc/implemented/architecture/2026- Indirectly, through spill consumers that render a backend locator and retrieval guidance. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The seam has no retrieval or deletion API** — consumers can only render the backend's locator and guidance; lifecycle and access semantics remain backend-specific. diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 62de4ffb08..9935233e85 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -12,6 +12,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 69a5f14181..0ce06fca2e 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -6,6 +6,8 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag `start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped. +The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent. + After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. `dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented. @@ -61,15 +63,31 @@ Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e ### Child-agent request -**What the model sees**: The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. +#### What the model sees -**Token effect**: The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context. +The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. + +#### Token effect + +The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context. + +#### KV Cache effect + +Independent of the parent request cache. Each ACP child can reuse only prefixes identical under its own provider, model, composition, and history; child steps otherwise grow append-only. ### Parent tool result, indirectly -**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: `. +#### What the model sees -**Token effect**: Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. +Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: `. + +#### Token effect + +Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index ec55845f91..fa16edcf60 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -36,6 +37,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.5", diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 9416d58865..a10a87a940 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -23,8 +23,8 @@ import { type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' @@ -149,9 +149,11 @@ function toError(value: unknown): Error { * @returns the ready run handle for the child subprocess. */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { - const id = AgentId(randomUUID()) - if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') + // ACP session ids are unique only within the child server. The lifecycle id + // is minted in the parent namespace so fresh processes cannot collide with + // each other or with a local agent that happens to use the same session id. + const id = SessionId(randomUUID()) // Keep diagnostics on parent stderr; only ACP output contributes to the result. const child = spawn(spec.command, spec.args, { @@ -241,7 +243,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe clientCapabilities: {}, }) const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId + const returnedSessionId: unknown = Reflect.get(session, 'sessionId') + if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id') + sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed.then((err): never => { throw err }), @@ -253,13 +257,18 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') throw toError(error) } + // The startup transaction validates the returned id before it can fulfill. + // This assertion carries that cross-closure invariant into TypeScript. + /* v8 ignore next */ + if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id') + const remoteSessionId = sessionId const result: Promise = (async (): Promise => { try { // Race the remote turn against local cancellation. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. - const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) + const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ @@ -285,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { id, + localAgent: undefined, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 1496f882aa..2bbf457c18 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -1,9 +1,45 @@ /** - * Minimal no-network ACP child process for keyless backend tests. Environment variables script its - * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a - * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark - * SIGTERM, or trap SIGTERM to require SIGKILL. The specs run this protocol-only fixture directly - * with Node's type stripping; it imports no harness code or workspace paths. + * A minimal mock ACP AGENT, run as a subprocess, for the keyless + * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is + * fully scripted by environment variables — no model, no network: + * + * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` + * (`end_turn` default, or `max_tokens`/`refusal`/…). + * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for + * a `session/cancel`), to exercise the client's cancel path. + * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives + * `session/cancel` but NEVER resolves the pending prompt + * and never exits — a non-cooperative child. The backend's + * `result` must still settle `aborted` on its own and + * `dispose()` must still kill the process. + * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` + * before answering, to exercise the client's auto-answer. + * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` + * handler is in flight (it has streamed its chunk). A test + * polls for this file to cancel on a CONDITION rather than + * an arbitrary timeout (subprocess cold-start is variable). + * - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new` + * response to exercise startup rollback. + * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat + * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real + * acp-agent's EOF-driven quiesce+flush, then touches this + * path and exits ON ITS OWN — no signal. Stands in for a + * child whose durable flush completes only if dispose + * gives EOF a real window before escalating to SIGTERM. + * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare + * timer) but install a SIGTERM handler that exits (and, if + * MOCK_SIGTERM_FILE is set, touches it as an observable + * proof the SIGTERM rung fired). The child ignores the + * graceful EOF window yet dies cooperatively on SIGTERM — + * exercising dispose's middle tier (exit during the SIGTERM + * grace, before the SIGKILL escalation). Touches + * MOCK_READY_FILE once armed. + * + * It is not a test spec: the specs launch this protocol-only fixture through + * the mode-aware example resolver (tsx in source mode, Node type stripping in + * built mode). It imports no harness code or workspace paths. + * * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ @@ -64,7 +100,8 @@ function makeAgent(conn: AgentSideConnection): Agent { writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) } - return { sessionId: randomUUID() } + if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse + return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() } }, authenticate(_params: AuthenticateRequest): Promise { // No auth methods advertised; nothing to do. @@ -124,8 +161,11 @@ function makeAgent(conn: AgentSideConnection): Agent { process.exit(1) } if (IGNORE_CANCEL) { - // A non-cooperative child receives cancellation but neither resolves nor exits. The - // backend must still settle `aborted`, and disposal must kill the process. + // A NON-COOPERATIVE child: receive session/cancel but never resolve the + // pending prompt and never exit. The backend's `result` must still settle + // `aborted` on its own (the cancel-settle race), and `dispose()` must + // still kill the process — proving cancellation does not depend on the + // child cooperating. return Promise.resolve() } resolveCancel?.('cancelled') @@ -142,9 +182,12 @@ new AgentSideConnection( ), ) -// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces -// on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL -// escalation. READY_FILE proves the trap was armed before the test disposes the run. +// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process +// neither quiesces on EOF nor dies on the graceful signal — exercising the +// backend dispose path's SIGKILL escalation. Without this the process exits +// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so +// a test waits for that CONDITION before disposing (the trap must be in place, +// not merely the process spawned — otherwise SIGTERM hits the default handler). if (process.env.MOCK_TRAP_SIGTERM === '1') { process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) // Keep the event loop alive (a bare timer) so nothing else lets it exit. @@ -152,10 +195,13 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') } -// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the -// dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and -// exit on its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves -// the EOF grace window was long enough for durable flush. +// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on +// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to +// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The +// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before +// the beat completes (no graceful window, or an EOF grace shorter than the +// flush) default-terminates this process and the marker is missing; a dispose +// that gives the EOF quiesce enough window first lets the flush land. if (FLUSH_ON_EOF !== undefined) { const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { @@ -166,9 +212,14 @@ if (FLUSH_ON_EOF !== undefined) { }) } -// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier before SIGKILL. The signal -// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE -// proves the handler was armed before disposal. +// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF +// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the +// child ignores the graceful EOF window yet dies cooperatively on SIGTERM, +// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the +// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an +// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle +// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs +// and the marker is missing. Touch READY_FILE once armed (a test waits on it). if (process.env.MOCK_IGNORE_EOF === '1') { const sigtermFile = process.env.MOCK_SIGTERM_FILE process.on('SIGTERM', () => { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index bfc8476bae..e4231c1598 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess' +import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' @@ -58,7 +58,7 @@ function text(blocks: { type: string; text?: string }[]): string { /** * Poll until `file` exists (the mock touches it once its prompt is in flight), * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the - * subprocess cold-start under tsx is variable, and a fixed sleep both flakes and + * subprocess cold-start is variable, and a fixed sleep both flakes and * slows the suite. Fails loud if the child never signals readiness. */ async function waitForFile(file: string, timeoutMs = 5000): Promise { @@ -108,7 +108,6 @@ describe('buildChildEnv', () => { // The explicitly-supplied key survives (an opt-in for the child's creds). expect(env.DEEPSEEK_API_KEY).toBe('explicit') // A normal ambient var is forwarded. - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(env.PATH).toBe(process.env.PATH) } finally { delete process.env.DSH_ACP_TEST_SECRET_TOKEN @@ -117,15 +116,22 @@ describe('buildChildEnv', () => { }) describe('dsh-subagent-acp', () => { - it('drives a child process to completion and returns its streamed output', async () => { - const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' }) + it('drives child processes with parent-unique run ids and returns streamed output', async () => { + const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) const run = await ctx.subagents.start('acp', request('do X')) + expect(run.id).not.toBe('acp-child-session') const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('hello from acp child') const disposal = run.dispose() expect(run.dispose()).toBe(disposal) await disposal + + const nextRun = await ctx.subagents.start('acp', request('do X again')) + expect(nextRun.id).not.toBe(run.id) + expect(nextRun.id).not.toBe('acp-child-session') + await nextRun.result + await nextRun.dispose() }) it('maps a max_tokens stop reason', async () => { @@ -184,6 +190,31 @@ describe('dsh-subagent-acp', () => { } }) + it('reaps a child whose session/new response omits the session id', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-')) + const flushed = join(tmp, 'flushed') + try { + await expect(startAcpRun(request(), { + command: process.execPath, + args: [mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { + MOCK_MISSING_SESSION_ID: '1', + MOCK_FLUSH_ON_EOF: flushed, + MOCK_FLUSH_DELAY_MS: '20', + }, + disposeEofGraceMs: 1000, + disposeGraceMs: 100, + })).rejects.toThrow('ACP child published without a session id') + // Startup rejects only after its private child reaches quiescence. The + // marker proves rollback closed stdin and allowed the child's EOF flush. + expect(existsSync(flushed)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => { // The child traps SIGTERM and keeps its event loop alive, so a graceful // term alone would hang dispose forever. With a short grace, dispose must diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index 344b42732d..5e4b9bf708 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -6,7 +6,7 @@ The fork provider creates an in-process child seeded with the parent's completed The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session. -Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. +Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority. @@ -27,15 +27,31 @@ See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, m ### Child-agent history and envelope -**What the model sees**: The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded. +#### What the model sees -**Token effect**: Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history. +The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded. + +#### Token effect + +Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history. + +#### KV Cache effect + +The child may reuse the inherited byte-identical prefix under the same provider and model. Persona, tool-filter, generated-SDK, or route changes may invalidate reuse before inherited history; later child history is append-only. ### Parent tool result, indirectly -**What the model sees**: The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work. +#### What the model sees -**Token effect**: Parent input grows by one data-dependent final result retained until compaction. +The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work. + +#### Token effect + +Parent input grows by one data-dependent final result retained until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index fa212bc938..a96ce4f06e 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -39,7 +39,7 @@ export const Config: z = z.object({ * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ -export function completedTurnPrefix(parent: Agent): SessionEvent[] { +function completedTurnPrefix(parent: Agent): SessionEvent[] { const events = parent.session.events const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 4a2c6b9133..df3b74d346 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -30,7 +30,7 @@ async function setup(script: Script) { await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index f1ca8f0b75..e228830554 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -10,7 +11,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' -import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -36,7 +36,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -44,28 +44,6 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } -describe('completedTurnPrefix', () => { - it('returns an empty prefix for a parent that has never completed a turn', async () => { - const { parent } = await setup([]) - expect(completedTurnPrefix(parent)).toEqual([]) - }) - - it('returns the balanced prefix up to and including the last turn/end', async () => { - const { parent } = await setup([textResponse('first'), textResponse('second')]) - parent.send([{ type: 'text', text: 'q1' }]) - await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) - await parent.whenIdle() - - const prefix = completedTurnPrefix(parent) - // Ends exactly at the last turn/end; seq is contiguous from 0. - expect(prefix.at(-1)?.type).toBe('turn/end') - expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) - // Both completed turns are present. - expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) - }) -}) - describe('dsh-subagent-fork', () => { it('emits subagent/start only after the seeded child is published', async () => { const { ctx, parent } = await setup([textResponse('child answer')]) @@ -88,7 +66,6 @@ describe('dsh-subagent-fork', () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. const { ctx, parent } = await setup([textResponse('fresh child')]) - expect(completedTurnPrefix(parent)).toEqual([]) const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') @@ -96,6 +73,24 @@ describe('dsh-subagent-fork', () => { const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.header.seedLength).toBeUndefined() + await run.dispose() + }) + + it('seeds every completed parent turn through the last turn/end', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.seedLength).toBe(parentPrefixLen) + expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index dc10405db7..0503db9dc4 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. -`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`. +Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. ## Structured output @@ -44,33 +44,65 @@ A clean turn that never commits the required structured value reports `error`; t ### Child-agent request -**What the model sees**: The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. +#### What the model sees -**Token effect**: Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance. +The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. + +#### Token effect + +Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance. + +#### KV Cache effect + +Independent of the parent request cache. The child's later history is append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix. ### Structured-output system prompt, schema, and results -**What the model sees**: A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. +#### What the model sees -**Token effect**: Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result. +A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. -#### Structured-output instruction +##### Structured-output instruction ```markdown When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result. ``` +#### Token effect + +Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result. + +#### KV Cache effect + +Prefix-stable inside the child while the structured-output instruction and schema are unchanged. Changing the schema or capability may invalidate the child's cache from that early segment; results append in child and parent histories. + ### Parent start error, indirectly -**What the model sees**: Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth exceeds maxDepth `. A pre-publication cancellation passes its abort reason through the registry's `Error: ` wrapper. +#### What the model sees -**Token effect**: Zero tokens on a successful start; only the failed parent tool call retains this text. +Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth exceeds maxDepth `. A pre-publication cancellation passes its abort reason through the registry's `Error: ` wrapper. + +#### Token effect + +Zero tokens on a successful start; only the failed parent tool call retains this text. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Parent result, indirectly -**What the model sees**: The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result. +#### What the model sees -**Token effect**: The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session. +The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result. + +#### Token effect + +The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 8ec8a6aac2..6e62ee5127 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-agent' { * @param agent - the agent whose options carry the depth. * @returns its non-negative safe-integer depth. */ -export function depthOf(agent: Agent): number { +function depthOf(agent: Agent): number { const depth = agent.options.subagentDepth if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { @@ -46,7 +46,7 @@ export function depthOf(agent: Agent): number { } /** Thrown when starting a child would exceed the requested depth cap. */ -export class SubagentDepthError extends Error { +class SubagentDepthError extends Error { constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) this.name = 'SubagentDepthError' @@ -104,7 +104,7 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = AgentId(randomUUID()) + const childId = SessionId(randomUUID()) const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header const parentProvider = parent.options.provider @@ -129,8 +129,7 @@ export async function startInProcessRun( const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), + sessionId: childId, meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, @@ -176,6 +175,7 @@ export async function startInProcessRun( return { id: childId, + localAgent: child, result, dispose(): Promise { request.signal.removeEventListener('abort', onAbort) diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 4222dde760..e50457bcb4 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -61,7 +61,7 @@ async function setup(script: Script, options: SetupOptions = {}) { start: (request: SubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter, disposeProvider } } @@ -322,7 +322,7 @@ describe('in-process structured output', () => { await expect(ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, }))).rejects.toThrow(/unsupported output schema/) - expect(ctx.agents.get(AgentId('parent'))).toBeDefined() + expect(ctx.agents.get(SessionId('parent'))).toBeDefined() }) it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index eeab453e40..13029ef3e7 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -17,7 +18,7 @@ async function setup(script: Script) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent } } @@ -29,19 +30,6 @@ function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } -describe('depthOf', () => { - it('reads zero for a top-level agent and an explicit child depth', async () => { - const { parent } = await setup([]) - expect(depthOf(parent)).toBe(0) - expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3) - }) - - it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => { - expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent)) - .toThrow('non-negative safe integer') - }) -}) - describe('startInProcessRun', () => { it('returns only after publication, drives a fresh child, and disposes it', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) @@ -50,7 +38,7 @@ describe('startInProcessRun', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver answer') - expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1) await run.dispose() await run.dispose() expect(ctx.agents.get(run.id)).toBeUndefined() @@ -75,7 +63,12 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) .rejects.toThrow('non-negative safe integer') await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) - .rejects.toBeInstanceOf(SubagentDepthError) + .rejects.toMatchObject({ name: 'SubagentDepthError' }) + for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = { options: { subagentDepth: value } } as unknown as Agent + await expect(startInProcessRun(request(malformed), {})) + .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') + } const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) }) diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 6f9982f15b..cd400689d7 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -22,15 +22,31 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers ### Child-agent request -**What the model sees**: The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. +#### What the model sees -**Token effect**: The child pays for a new independent context and history; no parent-history tokens are duplicated. Persona changes this child's repeated prompt cost, while filtering changes its schema or generated SDK cost. +The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. + +#### Token effect + +The child pays for a new independent context and history; no parent-history tokens are duplicated. Persona changes this child's repeated prompt cost, while filtering changes its schema or generated SDK cost. + +#### KV Cache effect + +Independent of the parent request cache. Child history grows append-only, while persona, tool-filter, generated-SDK, provider, or model changes establish a different child prefix. ### Parent tool result, indirectly -**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final output or stop-reason error. +#### What the model sees -**Token effect**: Parent input grows by one data-dependent result retained until compaction. +Through `dsh-tool-subagent`, the parent receives only the child's final output or stop-reason error. + +#### Token effect + +Parent input grows by one data-dependent result retained until compaction. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 1b278c9748..89efb2b815 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { spawnHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the in-process spawn backend: a REAL parent agent delegates @@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) + const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 147b83dd9a..7de5f6f4d6 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -9,7 +9,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] @@ -29,7 +29,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) return { ctx, parent, adapter } } @@ -110,11 +110,11 @@ describe('dsh-subagent-spawn', () => { it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { const { ctx, parent } = await setup([textResponse('x')]) - expect(depthOf(parent)).toBe(0) + expect(parent.options.subagentDepth).toBeUndefined() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! - expect(depthOf(child)).toBe(1) + expect(child.options.subagentDepth).toBe(1) await run.dispose() }) @@ -122,7 +122,7 @@ describe('dsh-subagent-spawn', () => { const { ctx, parent } = await setup([]) // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) - .rejects.toThrow(SubagentDepthError) + .rejects.toThrow('subagent depth 1 exceeds maxDepth 0') }) it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { @@ -225,7 +225,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('x')]) // A parent WITH a cwd (config agents have none, so create one explicitly). const parentHandle = await ctx.agents.create({ - agentId: AgentId('cwd-parent'), sessionId: SessionId('cwd-parent-session'), meta: { cwd: '/tmp/parent-workspace' }, agentOptions: { provider: 'mock', model: 'mock' }, @@ -242,7 +241,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('explicit model child')]) // A parent with NO model (its own turns would need one supplied per-request). const parentHandle = await ctx.agents.create({ - agentId: AgentId('modelless-parent'), sessionId: SessionId('modelless-parent-session'), agentOptions: {}, }) @@ -302,7 +300,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const controller = new AbortController() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], @@ -329,7 +327,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const parent = ctx.agentLoop.create(AgentId('parent'), { provider: 'mock', model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) const parentEffects = parent.ctx.fiber.getEffects().length const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) @@ -422,7 +420,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([]) // A handle-owned parent we can dispose (config agents dispose with the loop fiber). const parentHandle = await ctx.agents.create({ - agentId: AgentId('doomed-parent'), sessionId: SessionId('doomed-s'), agentOptions: { provider: 'mock', model: 'mock' }, }) @@ -445,7 +442,6 @@ describe('dsh-subagent-spawn', () => { it('parent disposal during the child setup transaction prevents every publication notification', async () => { const { ctx } = await setup([]) const parentHandle = await ctx.agents.create({ - agentId: AgentId('setup-race-parent'), sessionId: SessionId('setup-race-parent-session'), agentOptions: { provider: 'mock', model: 'mock' }, }) diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index a3c0905422..5ca9720f00 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -6,7 +6,7 @@ Every tunable is a **parameter**: the dispose ladder takes its grace periods per ## What it exports -### `SENSITIVE_ENV_PATTERN` / `buildChildEnv(extra)` +### `buildChildEnv(extra)` The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. @@ -14,10 +14,6 @@ The credential env scrub (same pattern as the [bash executor](../../bash/bash-lo Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. -### `waitForExit(child)` / `exitsWithin(child, ms)` - -Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child. - ### `disposeChildProcess(child, graces)` The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): @@ -28,6 +24,8 @@ The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. +The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. + ### `createIsolatedConfigDir(prefix, pinnedPath?)` A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. @@ -43,6 +41,10 @@ A per-run isolated config directory for an external CLI child (the target of `CL Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 21bcca788e..3831d2bb6a 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -20,13 +20,12 @@ import { join } from 'node:path' * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental * `AWS_SECRET_ACCESS_KEY` does not. */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names - * are dropped. + * a child CLI runs normally; only credential-shaped names are dropped. * @param extra - explicit vars layered on top AFTER the scrub, so a * credential-shaped name supplied deliberately still reaches the child. * @returns the environment to spawn the child with. @@ -57,7 +56,7 @@ export function spawnFailure(child: ChildProcess): Promise { * already gone. * @param child - the child process to await. */ -export function waitForExit(child: ChildProcess): Promise { +function waitForExit(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() return new Promise(resolve => child.once('exit', () => { resolve() })) } @@ -72,7 +71,7 @@ export function waitForExit(child: ChildProcess): Promise { * @returns `true` if the child exits within `ms` (immediately if it is * already gone), `false` on timeout. */ -export function exitsWithin(child: ChildProcess, ms: number): Promise { +function exitsWithin(child: ChildProcess, ms: number): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) return new Promise((resolve) => { const onExit = (): void => { diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index bdc0260c73..4b2552a4c6 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -9,10 +9,7 @@ import { buildChildEnv, createIsolatedConfigDir, disposeChildProcess, - exitsWithin, - SENSITIVE_ENV_PATTERN, spawnFailure, - waitForExit, } from '../src/index.ts' // `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm @@ -44,6 +41,8 @@ interface FakeChildScript { diesOn?: LethalTrigger /** Delay (ms) between the lethal trigger and the exit event. */ delayMs?: number + /** Complete the scripted exit inside the triggering call. */ + synchronousExit?: boolean /** `false` models a child spawned without a stdin pipe. */ stdin?: boolean } @@ -77,11 +76,13 @@ class FakeChild extends EventEmitter { // SIGKILL is uncatchable — it always fells the child; any other trigger // only when the scenario scripts it as the lethal one. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - setTimeout(() => { + const exit = (): void => { if (trigger === 'eof') this.exitCode = 0 else this.signalCode = trigger this.emit('exit', this.exitCode, this.signalCode) - }, this.script.delayMs ?? 0) + } + if (this.script.synchronousExit === true) exit() + else setTimeout(exit, this.script.delayMs ?? 0) } } @@ -90,7 +91,7 @@ function asChild(fake: FakeChild): ChildProcess { return fake as unknown as ChildProcess } -describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { +describe('buildChildEnv', () => { it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { process.env.DSH_PROC_TEST_API_KEY = 'leak' process.env.dsh_proc_test_secret = 'leak' @@ -108,7 +109,6 @@ describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { }) it('forwards normal ambient vars', () => { - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(buildChildEnv({}).PATH).toBe(process.env.PATH) }) @@ -146,7 +146,7 @@ describe('spawnFailure', () => { const fake = new FakeChild({ diesOn: 'SIGTERM' }) const failure = spawnFailure(asChild(fake)) fake.kill('SIGTERM') - await waitForExit(asChild(fake)) + await new Promise(resolve => fake.once('exit', () => { resolve() })) // A clean lifecycle emits `exit`, never `error` — the capture stays // pending forever, so a race against it is decided by the other arms. const settled = await Promise.race([ @@ -157,51 +157,6 @@ describe('spawnFailure', () => { }) }) -describe('waitForExit / exitsWithin', () => { - it('resolves immediately for a child that already exited by code', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves immediately for a child that already died by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGTERM' - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves on the exit event of a live child', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - const exited = waitForExit(asChild(fake)) - fake.kill('SIGTERM') - await expect(exited).resolves.toBeUndefined() - expect(fake.signalCode).toBe('SIGTERM') - }) - - it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves true when the child exits inside the window', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - fake.kill('SIGTERM') - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - // The once-listener fired and the grace timer was cleared — nothing lingers. - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves false on timeout for a child that never exits', async () => { - const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent - await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) - // The timeout arm removed its exit listener: repeated waits (a poll loop, - // the ladder's tiers) never accumulate listeners on the same child. - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - describe('disposeChildProcess', () => { it('returns immediately for an already-exited child (no EOF, no signals)', async () => { const fake = new FakeChild() @@ -227,12 +182,28 @@ describe('disposeChildProcess', () => { expect(fake.exitCode).toBe(0) }) + it('recognizes a child that exits synchronously on stdin EOF', async () => { + const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.exitCode).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) expect(fake.stdinEnded).toBe(true) expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('recognizes a child that exits synchronously on SIGTERM', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) }) it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { @@ -244,6 +215,13 @@ describe('disposeChildProcess', () => { expect(fake.signalCode).toBe('SIGKILL') }) + it('recognizes a child already gone when the final exit wait begins', async () => { + const fake = new FakeChild({ synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 6f1f5fa40a..c810a1f6ce 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -50,7 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. + +The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. @@ -64,6 +66,10 @@ The model-facing tool collects synchronously by default: it awaits the child res Indirectly, through `dsh-tool-subagent`, which renders provider-specific schemas and foreground or generic-background results while child working context remains child-only. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Runtime steering and continuation are seam-only capabilities** — `sendMessage` and `resume` have no model-facing consumer in the current tool. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 0b09033fd3..aea05553e4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -23,15 +23,19 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 0d156ae725..19779f9137 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -28,13 +28,15 @@ * @module @deepseek-ai/dsh-subagent */ +import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -42,7 +44,9 @@ import type { SubagentRun, SubagentStartRequest, } from './types.ts' +import { SubagentRunId } from './types.ts' +export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, SubagentProvider, @@ -111,18 +115,26 @@ declare module 'cordis' { /** Observe-only identifying detail for a ready subagent run. */ export interface SubagentRunInfo { + /** Unique identity shared with the paired terminal event. */ + readonly runId: SubagentRunId /** The provider that established the run. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean } /** Observe-only outcome detail for a settled subagent run. */ export interface SubagentRunEndInfo { + /** Unique identity shared with the paired start event. */ + readonly runId: SubagentRunId /** The provider that ran it. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ + readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** The child's final assistant output, absent on infrastructure rejection. */ @@ -207,22 +219,28 @@ export class SubagentService extends Service { const parent = request.parent const run = await provider.start(request) + const runId = SubagentRunId(randomUUID()) + const lifecycleIdentity = { + runId, + provider: name, + id: run.id, + local: run.localAgent !== undefined, + } // Attach the terminal observer before dispatching start. Promise reactions // still run after this synchronous start emission, preserving start → end. void run.result.then( (result) => { this.emitLifecycle('subagent/end', { - provider: name, - id: run.id, + ...lifecycleIdentity, stopReason: result.stopReason, lastAssistantMessage: result.output, }, parent) }, () => { - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) + this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent) }, ) - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + this.emitLifecycle('subagent/start', lifecycleIdentity, parent) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 05bb40d575..1b1645d89b 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,10 +6,24 @@ * @module @deepseek-ai/dsh-subagent/types */ -import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +/** Identifies one accepted subagent run across its lifecycle event pair. */ +export type SubagentRunId = Branded<'SubagentRunId'> + +/** + * Brand a string as a {@link SubagentRunId}. + * @param id - the raw id string (the service mints UUIDs; tests may pass fixtures). + * @returns the same string, branded. + */ +export function SubagentRunId(id: string): SubagentRunId { + return id as SubagentRunId +} + /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks @@ -132,8 +146,18 @@ export interface SubagentResult { * capability discovery; narrow their presence before calling. */ export interface SubagentRun { - /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ - readonly id: AgentId + /** + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. + */ + readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ + readonly localAgent: Agent | undefined /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3d10b425ed..66008a923b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { @@ -12,9 +13,10 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } @@ -45,7 +47,8 @@ class StubProvider implements SubagentProvider { async start(request: SubagentStartRequest): Promise { this.startCount += 1 return { - id: AgentId(`child:${this.name}:${request.parent.id}`), + id: SessionId(`child:${this.name}:${request.parent.id}`), + localAgent: undefined, result: Promise.resolve(this.outcome), async dispose() {}, } @@ -135,13 +138,14 @@ describe('SubagentService', () => { const parent = fakeParent('delegator') const events: string[] = [] const keys: unknown[] = [] - ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) }) - ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) }) + const runIds: string[] = [] + ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) + ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) const starting = subagents.start('deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -149,6 +153,21 @@ describe('SubagentService', () => { await Promise.resolve() expect(events).toEqual(['start', 'end']) expect(keys).toEqual([parent, parent]) + expect(runIds[0]).toBe(runIds[1]) + }) + + it('mints distinct lifecycle identities when provider and child ids repeat', async () => { + const { ctx, subagents } = await service() + subagents.registerProvider(new StubProvider('reused')) + const runIds: string[] = [] + ctx.on('subagent/start', info => void runIds.push(info.runId)) + + const first = await subagents.start('reused', baseRequest()) + const second = await subagents.start('reused', baseRequest()) + await Promise.all([first.result, second.result]) + + expect(runIds).toHaveLength(2) + expect(new Set(runIds).size).toBe(2) }) it('emits no run lifecycle when provider startup rejects', async () => { @@ -190,7 +209,7 @@ describe('SubagentService', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} } }, }) const failedRun = await subagents.start('infra', baseRequest()) diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index b125839d75..8d382b6ce4 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -24,25 +24,53 @@ With `run_in_background: true`, the tool registers the parent-owned task before | `toolFilter` | Per-child global-tool restriction; requires `toolFilter` capability. | | `maxDepth` | Absolute delegation-depth cap; requires `depthLimit` capability. | +## Concurrency + +Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call RFC](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). + ## Model Experience ### Tool schema -**What the model sees**: The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`. +#### What the model sees -**Token effect**: Fixed schema cost per parent request; each provider instance adds one schema. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. Provider context inheritance changes the tool and prompt descriptions; enabled background mode adds `run_in_background`. + +#### Token effect + +Fixed schema cost per parent request; each provider instance adds one schema. + +#### KV Cache effect + +Prefix-stable while provider instances, names, descriptions, and schemas are unchanged. Provider registration lifecycle may invalidate parent reuse from the first changed tool definition. ### Foreground result -**What the model sees**: The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. +#### What the model sees -**Token effect**: The prompt and result remain in parent history until compaction; child working context remains in the child. +The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. + +#### Token effect + +The prompt and result remain in parent history until compaction; child working context remains in the child. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Background task result -**What the model sees**: Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. +#### What the model sees -**Token effect**: The acknowledgement is retained; final output enters parent history only when collected or injected. +Start returns exactly `started background subagent task `. The generic task surface provides later status, final output, cancellation responses, and notices. + +#### Token effect + +The acknowledgement is retained; final output enters parent history only when collected or injected. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index c52f5a74dc..e2d4378743 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-mock": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index fdfaacef08..f46f3dda2c 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -161,13 +161,13 @@ export async function settleRun(run: SubagentRun): Promise { * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false - * for a fork. Exported for tests. + * for a fork. * @param inheritsConversation - whether the child's conversation is seeded * with the parent's completed turns; this says nothing about tool, service, * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { +function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { if (inheritsConversation) { return { description: diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts new file mode 100644 index 0000000000..7365348410 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { type Agent } from '@deepseek-ai/dsh-agent' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as scripted from './scripted-provider.ts' + +/** A minimal parent; the scripted provider only reads its id. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: SessionId(id) } as unknown as Agent +} + +function baseRequest(over: Partial = {}): SubagentStartRequest { + return { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent(), + signal: new AbortController().signal, + ...over, + } +} + +async function mount(config: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(SubagentService) + await scripted.mountScriptedProvider(ctx, { name: 'mock', ...config }) + return ctx +} + +describe('scripted subagent provider fixture', () => { + it('registers through the real service and returns the scripted reply', async () => { + const ctx = await mount({ reply: 'hello from fixture' }) + expect(ctx.subagents.list()).toEqual(['mock']) + + const run = await ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'hello from fixture' }], + structured: undefined, + stopReason: 'completed', + }) + await run.dispose() + }) + + it('registers under a configurable name', async () => { + const ctx = await mount({ name: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + }) + + it('returns configured and default structured results', async () => { + const configured = await mount({ reply: 'r', structured: { answer: 42 } }) + const schema = { type: 'object' as const, properties: { answer: { type: 'number' as const } } } + const configuredRun = await configured.subagents.start('mock', baseRequest({ outputSchema: schema })) + await expect(configuredRun.result).resolves.toMatchObject({ structured: { answer: 42 } }) + + const fallback = await mount({ reply: 'fallback reply' }) + const fallbackRun = await fallback.subagents.start('mock', baseRequest({ outputSchema: schema })) + await expect(fallbackRun.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) + }) + + it('omits structured output when no schema is requested', async () => { + const ctx = await mount({ capabilities: { outputSchema: false } }) + const run = await ctx.subagents.start('mock', baseRequest()) + expect(await run.result).not.toHaveProperty('structured') + }) + + it('honors configured and cancellation stop reasons', async () => { + const refused = await mount({ stopReason: 'refusal' }) + const refusedRun = await refused.subagents.start('mock', baseRequest()) + await expect(refusedRun.result).resolves.toMatchObject({ stopReason: 'refusal' }) + + const cancelled = await mount() + const controller = new AbortController() + const cancelledRun = await cancelled.subagents.start('mock', baseRequest({ signal: controller.signal })) + controller.abort() + await expect(cancelledRun.result).resolves.toMatchObject({ stopReason: 'aborted' }) + }) + + it('rejects cancellation before or during asynchronous publication', async () => { + const ctx = await mount() + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(ctx.subagents.start('mock', baseRequest({ signal: alreadyAborted.signal }))) + .rejects.toThrow('scripted subagent start aborted before publication') + + const handoff = new AbortController() + const pending = ctx.subagents.start('mock', baseRequest({ signal: handoff.signal })) + handoff.abort() + await expect(pending).rejects.toThrow('scripted subagent start aborted before publication') + }) + + it('unregisters with its owning fixture fiber', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' }) + expect(ctx.subagents.list()).toEqual(['mock']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) +}) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts new file mode 100644 index 0000000000..01c0769cf8 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -0,0 +1,104 @@ +/** Package-local scripted child boundary for deterministic tool-subagent tests. */ + +import type { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' + +const DEFAULT_CAPABILITIES: SubagentCapabilities = { + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, +} + +/** Options for one scripted provider fixture. */ +export interface Config { + /** Registry name to register under. */ + name: string + /** Final text returned by the scripted child. */ + reply?: string + /** Terminal result reason. */ + stopReason?: SubagentStopReason + /** Start-time features advertised by the provider. */ + capabilities?: Partial + /** Whether tool descriptions say the child inherits completed turns. */ + inheritsParentContext?: boolean + /** Structured value returned when the request asks for one. */ + structured?: unknown +} + +/** Scripted provider whose result aborts if its signal or disposer wins first. */ +class ScriptedSubagentProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean + + constructor( + readonly name: string, + private readonly config: Config, + ) { + this.capabilities = { ...DEFAULT_CAPABILITIES, ...config.capabilities } + this.inheritsParentContext = config.inheritsParentContext ?? false + } + + async start(request: SubagentStartRequest): Promise { + if (request.signal.aborted) throw new Error('scripted subagent start aborted before publication') + const reply = this.config.reply ?? 'scripted subagent reply' + const output: ContentBlock[] = [{ type: 'text', text: reply }] + const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema + const stopReason = this.config.stopReason ?? 'completed' + const state = { cancelled: false } + const onAbort = (): void => { state.cancelled = true } + request.signal.addEventListener('abort', onAbort, { once: true }) + await Promise.resolve() + if (state.cancelled) { + request.signal.removeEventListener('abort', onAbort) + throw new Error('scripted subagent start aborted before publication') + } + + const resultFor = (): SubagentResult => ({ + output, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + stopReason: state.cancelled ? 'aborted' : stopReason, + }) + const result = new Promise((resolve) => { + setTimeout(() => { resolve(resultFor()) }, 0) + }).finally(() => { + request.signal.removeEventListener('abort', onAbort) + }) + + return { + id: SessionId(`scripted-subagent:${this.name}:${request.parent.id}`), + localAgent: undefined, + result, + dispose(): Promise { + state.cancelled = true + request.signal.removeEventListener('abort', onAbort) + return Promise.resolve() + }, + } + } +} + +/** + * Mount one scripted provider through an effect-scoped local plugin. + * @param ctx - context carrying the real subagent registry. + * @param config - scripted provider identity and outcome. + * @returns the fixture plugin's disposable fiber. + */ +export function mountScriptedProvider(ctx: Context, config: Config) { + return ctx.plugin({ + name: 'scripted-subagent-provider', + inject: ['subagents'], + apply(pluginCtx: Context): void { + pluginCtx.subagents.registerProvider(new ScriptedSubagentProvider(config.name, config)) + }, + }) +} diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9a5f369f8b..d88367ced5 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,27 +4,27 @@ import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' -import * as mock from '@deepseek-ai/dsh-subagent-mock' +import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { runOutcome, settleRun } from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real - * `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the - * backend, and invokes the registered `subagent` tool through - * `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the - * "child agent", the expensive/non-deterministic boundary) — everything - * downstream of the tool is the shipping code path. + * `ToolRegistry` + `SubagentService`, with a package-local scripted child + * boundary, and invokes the registered `subagent` tool through + * `ctx.tools.execute`. Everything downstream of the child boundary is the + * shipping code path. */ /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ function fakeAgent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -32,7 +32,7 @@ async function setup(toolConfig: tool.Config, mockConfig: Partial = await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock', ...mockConfig }) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) await ctx.plugin(tool, toolConfig) return ctx } @@ -85,7 +85,7 @@ describe('dsh-tool-subagent', () => { // Schema omission is advertising, not enforcement: the arg validator // allows undeclared keys, so the opt-out must also hold in execute(). const ctx = await setup({ provider: 'mock', enableRunInBackground: false }) - const parent = { id: AgentId('agent-sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent + const parent = { id: SessionId('sess-off'), inject: () => {}, session: { header: { version: 0, id: 'sess-off', createdAt: 0 } } } as unknown as Agent const forced = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }, { agent: parent }) expect(forced.isError).toBe(true) @@ -96,6 +96,20 @@ describe('dsh-tool-subagent', () => { expect(foreground.isError).toBe(false) }) + it('keeps foreground and background calls exclusive', async () => { + const ctx = await setup({ provider: 'mock' }) + expect(ctx.tools.executionMode({ + callId: CallId('subagent-foreground'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK' }, + })).toEqual({ kind: 'exclusive' }) + expect(ctx.tools.executionMode({ + callId: CallId('subagent-background'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, + })).toEqual({ kind: 'exclusive' }) + }) + it.each([ { stopReason: 'aborted' as const, fragment: 'cancelled' }, { stopReason: 'error' as const, fragment: 'failed' }, @@ -116,8 +130,8 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' }) - await ctx.plugin(mock, { name: 'acp', reply: 'from acp' }) + await mock.mountScriptedProvider(ctx, { name: 'spawn', reply: 'from spawn' }) + await mock.mountScriptedProvider(ctx, { name: 'acp', reply: 'from acp' }) await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' }) await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' }) @@ -142,7 +156,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('weird-child'), + id: SessionId('weird-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), dispose: async () => {}, }), @@ -169,7 +184,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture-child'), + id: SessionId('capture-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -198,7 +214,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('bare-child'), + id: SessionId('bare-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -231,7 +248,7 @@ describe('dsh-tool-subagent', () => { tool.apply(ctx, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) // Backend arrives (as a delayed sibling fiber would): the tool appears. - await ctx.plugin(mock, { name: 'mock', reply: 'late but fine' }) + await mock.mountScriptedProvider(ctx, { name: 'mock', reply: 'late but fine' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(text(result)).toBe('late but fine') @@ -242,7 +259,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false) + const backend = await mock.mountScriptedProvider(ctx, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') @@ -252,7 +269,7 @@ describe('dsh-tool-subagent', () => { // Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived // from the fresh provider, not served stale from the first mount. - await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) + await mock.mountScriptedProvider(ctx, { name: 'mock', inheritsParentContext: true }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('inherits this conversation') }) @@ -263,7 +280,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) // Arm 1: a mounted tool dies with its plugin fiber; the provider survives. - await ctx.plugin(mock, { name: 'mock' }) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) const mounted = await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) await mounted.dispose() @@ -275,7 +292,7 @@ describe('dsh-tool-subagent', () => { // live plugin owns (the zombie mount). const waiting = await ctx.plugin(tool, { provider: 'later', toolName: 'subagent_later' }) await waiting.dispose() - await ctx.plugin(mock, { name: 'later' }) + await mock.mountScriptedProvider(ctx, { name: 'later' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent_later')).toBe(false) }) @@ -284,11 +301,11 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock' }) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock' }) // An unrelated provider registering (added-event with another name) and // unregistering (removed-event with another name) must not touch the tool. - const other = await ctx.plugin(mock, { name: 'other', inheritsParentContext: true }) + const other = await mock.mountScriptedProvider(ctx, { name: 'other', inheritsParentContext: true }) expect(ctx.tools.schemas().filter(s => s.name === 'subagent')).toHaveLength(1) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') await other.dispose() @@ -325,7 +342,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => void disposed(), }), @@ -347,7 +365,8 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'error' as const }), dispose: async () => void disposed(), }), @@ -378,7 +397,8 @@ describe('dsh-tool-subagent', () => { resolveResult({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('spy-child'), + id: SessionId('spy-child'), + localAgent: undefined, result, dispose: async () => {}, } @@ -470,7 +490,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture2-child'), + id: SessionId('capture2-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -527,7 +548,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture3-child'), + id: SessionId('capture3-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -556,7 +578,8 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture4-child'), + id: SessionId('capture4-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -588,11 +611,12 @@ describe('dsh-tool-subagent background mode', () => { /** A live parent with a dedicated scope fiber for structural task cleanup. */ function ownerAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: AgentId(`agent-${sessionId}`), + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(agent) return agent @@ -722,7 +746,7 @@ describe('dsh-tool-subagent background mode', () => { inheritsParentContext: false, start: async (request) => { let settle!: (value: { output: { type: 'text'; text: string }[]; stopReason: 'aborted' }) => void - const id = AgentId(`hang-${++starts}`) + const id = SessionId(`hang-${++starts}`) const result = new Promise<{ output: { type: 'text'; text: string }[]; stopReason: 'aborted' }>((res) => { settle = res }) request.signal.addEventListener('abort', () => { cancels.push(typeof request.signal.reason === 'string' ? request.signal.reason : undefined) @@ -730,6 +754,7 @@ describe('dsh-tool-subagent background mode', () => { }, { once: true }) return { id, + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -768,7 +793,8 @@ describe('dsh-tool-subagent background mode', () => { it('settleRun disposes the run before reporting, on both result paths', async () => { const order: string[] = [] const completed = await settleRun({ - id: AgentId('child-1'), + id: SessionId('child-1'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), dispose() { order.push('dispose'); return Promise.resolve() }, }) @@ -779,7 +805,8 @@ describe('dsh-tool-subagent background mode', () => { // An infrastructure rejection still disposes and reports failed. let disposed = false const failed = await settleRun({ - id: AgentId('child-2'), + id: SessionId('child-2'), + localAgent: undefined, result: Promise.reject(new Error('transport gone')), dispose() { disposed = true; return Promise.resolve() }, }) @@ -787,14 +814,16 @@ describe('dsh-tool-subagent background mode', () => { expect(disposed).toBe(true) const disposeFailed = await settleRun({ - id: AgentId('child-3'), + id: SessionId('child-3'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' }), dispose: () => Promise.reject(new Error('reap failed')), }) expect(disposeFailed).toEqual({ status: 'failed', detail: 'dispose failed: Error: reap failed' }) const bothFailed = await settleRun({ - id: AgentId('child-4'), + id: SessionId('child-4'), + localAgent: undefined, result: Promise.reject(new Error('result failed')), dispose: () => Promise.reject(new Error('reap failed')), }) @@ -812,11 +841,12 @@ describe('background preflight failure (no orphaned child, by construction)', () await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) const scopeFiber = ctx.plugin(() => {}) + const id = SessionId('sess-p') const parent = { - id: AgentId('agent-sess-p'), + id, ctx: scopeFiber.ctx, inject: () => {}, - session: { header: { version: 0, id: 'sess-p', createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent ctx.agents.register(parent) @@ -828,7 +858,8 @@ describe('background preflight failure (no orphaned child, by construction)', () start: async () => { starts += 1 return { - id: AgentId('probe-child'), + id: SessionId('probe-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' as const }), dispose: () => Promise.resolve(), } diff --git a/packages/support/README.md b/packages/support/README.md index a85fffcdac..433d6b3cdb 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -4,11 +4,10 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| -| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | +| `acp-snapshot/` | ACP test kit: shared subprocess/client launcher + snapshot harness, normalizers, and suite factory | (library — imported by ACP e2e and `*.snapshot.ts` suites) | | `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) | | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | -| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 222d572043..940be0ee31 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -2,11 +2,12 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example. -Three layers, importable separately: +Four layers, importable separately: +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo). Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: @@ -37,14 +38,18 @@ defineAcpSnapshotSuite({ A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. -Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. +Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). ## Model Experience None, as this test-only harness records, normalizes, and compares ACP transcripts without changing the agent's assembled model request. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path. diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index eb6b46deaf..64e1b0cbd7 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", - "description": "ACP snapshot suite kit: real-subprocess scenario harness, golden normalizers, and the suite factory behind the keyless snapshot tier", + "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index e2072b6b17..542b6d6df3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -1,59 +1,46 @@ /** - * Shared ACP snapshot subprocess harness. It boots the real agent bin through the Cordis - * loader, drives deterministic ACP JSON-RPC over stdio, captures protocol-pure stdout, and - * harvests persisted session logs after graceful shutdown. Normalization stays in - * `normalize.ts`; suite registration stays in `suite.ts`. + * Shared subprocess harness for ACP snapshot suites. A library module driven by + * the suite factory in ./suite.ts (and directly by harness-level specs); each + * example's `*.snapshot.ts` names its own agent-under-test paths. + * + * It boots the REAL agent bin subprocess via the cordis Loader (so the + * export-shape bug class stays guarded — see docs/postmortem/0001), drives it + * over real ACP JSON-RPC stdio with a deterministic input script, tees raw + * stdout (for the golden + a purity check) into an SDK `ClientSideConnection`, + * and — in record mode — harvests the persisted session JSONL after a graceful + * shutdown flush. The pure normalizers in ./normalize.ts turn the captured + * stdout frames and the session-log events into stable, snapshot-able text. + * + * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. + * * @module @deepseek-ai/dsh-acp-snapshot/harness */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { Readable, Writable } from 'node:stream' import { ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from './launcher.ts' + +export type { AgentUnderTest } from './launcher.ts' /** - * The agent composition a scenario runs against: which bin to boot and which - * leaf config it loads. All paths are ABSOLUTE — the subprocess cwd is a temp - * dir outside the repo, so relative resolution would miss; a suite resolves - * them from its own `import.meta.url`. - */ -export interface AgentUnderTest { - /** The agent bin's SOURCE entry (e.g. `packages/examples/acp-demo/src/bin.ts`); the `lib` bin is derived from it. */ - binScript: string - /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ - libBinScript?: string | undefined - /** - * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps - * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so - * one path serves both modes. - */ - configPath: string - /** - * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports in `src` mode (passed to the child as `TSX_TSCONFIG_PATH`). Ignored - * in `lib` mode, where the example resolves plugins through real `exports`. - */ - tsconfigPath: string -} - -/** - * One step of a scenario's deterministic input script (`input.json`). The harness interprets - * these in order. `newSession` captures the server-issued (random) session id into a - * `{{sessionId}}` variable that later steps reference. `promptAndCancel` sends without awaiting, - * waits for the first streamed message, then cancels, making transcript order deterministic. + * One step of a scenario's deterministic input script (`input.json`). The + * harness interprets these in order. `newSession` captures the server-issued + * (random) session id into a `{{sessionId}}` variable that later steps + * reference, since a committed file cannot know the id in advance. + * + * `promptAndCancel` starts a prompt without awaiting completion, waits until + * the client observes the selected update (`agent_message_chunk` by default), + * then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the + * step open for a terminal tool update that may follow the prompt response. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -61,7 +48,12 @@ export type InputStep = | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } | { op: 'promptExpectError'; text: string } - | { op: 'promptAndCancel'; text: string } + | { + op: 'promptAndCancel' + text: string + afterUpdate?: 'agent_message_chunk' | 'tool_call' + waitForToolCallUpdate?: string + } | { op: 'cancel' } | { op: 'setConfigOption'; configId: string; value: string } | { op: 'setConfigOptionExpectError'; configId: string; value: string } @@ -70,9 +62,16 @@ export type InputStep = export interface InputScript { steps: InputStep[] /** - * FIFO permission answers selected by stable option kind; the harness maps each kind to the - * agent-issued option id. Exhaustion cancels, while a kind the agent did not offer fails the - * scenario. + * Ordered answers for the agent's `session/request_permission` round-trips, + * consumed FIFO — the Nth request gets the Nth answer. Each answer selects + * by option KIND: option ids are agent-issued randoms a committed script + * cannot know, while kinds are the ACP-stable vocabulary, so the client maps + * kind → the offered `optionId` at answer time. A request beyond the queue + * (or with no queue at all) is answered `cancelled` — the stub behavior a + * scenario without approvals relies on. A scripted kind the request does + * not offer REJECTS the run: the scenario scripted an impossible click, + * and {@link runScenario} throws once the in-flight step settles (the + * agent itself just sees `cancelled`, so it cannot absorb the bug). */ permissionAnswers?: PermissionAnswer[] } @@ -165,93 +164,47 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Fixed path length: spill-policy budgets the preview against the REAL path // before stdout normalization, so tmpdir() length differences churn goldens. const spillRoot = '/tmp/dsh-acp-snapshot-spill' - // Everything past the temp-dir creation runs under a try/finally that always - // removes both dirs — so a failure in workspace seeding, spawn, or any step - // never leaks them (the "e2e tests own their resources" rule). - let child: ChildProcessWithoutNullStreams | undefined + // Everything past the temp-dir creation is followed by failure-safe cleanup, + // so a failure in workspace seeding, spawn, or any step never leaks resources. + let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - const rawBuffers: Buffer[] = [] - const stderrChunks: string[] = [] - try { + const outcome = await (async (): Promise => { // Seed the workspace if the scenario ships one (a file the agent reads/edits). + // Copied into the temp cwd so the agent's bash tools see it; the goldens + // normalize the cwd, so the seeded paths stay stable across runs. if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } - // Boot the agent in the environment's mode (DSH_EXAMPLE_MODE): `src` runs the - // source bin under tsx with the paths map; `lib` runs the built bin under plain - // Node, resolving plugins through the example's workspace node_modules → lib. - const launch = resolveExampleLaunch({ - srcBin: opts.agent.binScript, - libBin: opts.agent.libBinScript, - configArgs: ['--config', opts.configPath ?? opts.agent.configPath], - tsconfigPath: opts.agent.tsconfigPath, - env: { - DSH_SNAPSHOT: opts.mode, - DSH_SNAPSHOT_FILE: opts.fixtureFile, - DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_SNAPSHOT_SPILL_ROOT: spillRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, - ...opts.childFiles !== undefined && opts.childFiles.length > 0 - ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } - : {}, - }, - }) - - child = spawn( - launch.command, - launch.args, - { cwd, env: { ...process.env, ...launch.env }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => stderrChunks.push(c)) - - // Tee the same raw bytes to the golden and SDK client. Decode once at the end so a UTF-8 - // sequence split across stream chunks cannot corrupt the transcript. - const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buf: Buffer) => { - rawBuffers.push(buf) - passthrough.push(buf) - }) - child.stdout.on('end', () => passthrough.push(null)) - - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(passthrough) as ReadableStream, - ) - // Watcher so a step can block until the client OBSERVES a particular - // session/update — used by promptAndCancel to pin frame order (send cancel - // only after the streamed agent_message_chunk has arrived, so those frames - // deterministically precede the cancelled prompt response). - const updateWaiters: { match: (u: SessionNotification['update']) => boolean; resolve: () => void }[] = [] - const waitForUpdate = (match: (u: SessionNotification['update']) => boolean): Promise => - new Promise(resolve => updateWaiters.push({ match, resolve })) + const env: NodeJS.ProcessEnv = { + DSH_SNAPSHOT: opts.mode, + DSH_SNAPSHOT_FILE: opts.fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + DSH_SNAPSHOT_SPILL_ROOT: spillRoot, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, + } // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] - // A callback throw would become only an RPC error the agent could absorb. Record an - // impossible permission choice here, answer cancelled, and fail the outer scenario. + // A scenario bug detected inside a client callback (a scripted permission + // kind the agent never offered). It cannot fail the run from in there: a + // callback throw only becomes a JSON-RPC error RESPONSE to the agent, and + // a tolerant agent treats that as a denial and carries on — the run (or + // worse, a record) would absorb the impossible click silently. So the + // callback answers `cancelled` (a well-defined path for the agent), + // captures the error here, and the step loop fails the run on it. let scriptError: Error | undefined - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - for (let i = updateWaiters.length - 1; i >= 0; i--) { - const waiter = updateWaiters[i] - // The index is always in-bounds (i only decreases; splice removes at - // i, so lower entries stay valid); the guard satisfies - // noUncheckedIndexedAccess. - /* v8 ignore next 1 -- unreachable in-bounds guard, see above */ - if (waiter === undefined) continue - if (waiter.match(params.update)) { - updateWaiters.splice(i, 1) - waiter.resolve() - } - } - return Promise.resolve() - }, + launched = launchAcpTestAgent({ + agent: opts.agent, + cwd, + ...opts.configPath !== undefined ? { configPath: opts.configPath } : {}, + env, requestPermission(params: RequestPermissionRequest): Promise { const answer = permissionQueue.shift() if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) @@ -269,10 +222,12 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) + const active = launched + await active.spawned + const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, waitForUpdate, () => sessionId, (id) => { sessionId = id }) + await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -281,35 +236,57 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise } // Done driving: close stdin so the server disposes gracefully (flushing // persistence) and exits. Then await exit so the harvested log is complete. - child.stdin.end() - await waitForExit(child) + await active.close() // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) - } catch (error: unknown) { - const stderr = stderrChunks.join('') - if (stderr === '') throw error - throw new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error }) - } finally { - // Failure-safe teardown: kill a still-running child and drop the temp dirs - // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `child` is undefined only if spawn itself threw. - if (child !== undefined && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL') - await waitForExit(child) + return { + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), + cwd, + ...sessionId !== undefined ? { sessionId } : {}, + sessionLogs, } - await rm(cwd, { recursive: true, force: true }) - await rm(sessionsRoot, { recursive: true, force: true }) - await rm(spillRoot, { recursive: true, force: true }) - } + })().then( + value => ({ status: 'fulfilled', value } as const), + (error: unknown) => { + const stderr = launched?.stderr() ?? '' + return { + status: 'rejected', + error: stderr === '' + ? error + : new Error(`snapshot-harness: scenario failed: ${String(error)}\nagent stderr:\n${stderr}`, { cause: error }), + } as const + }, + ) - return { - rawStdout: Buffer.concat(rawBuffers).toString('utf8'), - stderr: stderrChunks.join(''), - cwd, - ...sessionId !== undefined ? { sessionId } : {}, - sessionLogs, + // Failure-safe teardown: wait for a still-running child, then attempt every + // owned-path removal even when an earlier cleanup rejects. Report every + // teardown failure alongside a scenario failure so neither orthogonal + // outcome hides the other. + const cleanupResults: PromiseSettledResult[] = [] + const cleanup = async (action: () => Promise): Promise => { + cleanupResults.push(...await Promise.allSettled([action()])) } + /* v8 ignore next 1 -- launch itself can only throw on a defensive synchronous spawn API failure */ + await cleanup(() => launched?.close('SIGKILL') ?? Promise.resolve()) + await cleanup(() => rm(cwd, { recursive: true, force: true })) + await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + await cleanup(() => rm(spillRoot, { recursive: true, force: true })) + + const cleanupFailures = cleanupResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (cleanupFailures.length > 0) { + throw new AggregateError( + outcome.status === 'rejected' ? [outcome.error, ...cleanupFailures] : cleanupFailures, + outcome.status === 'rejected' + ? 'snapshot scenario and cleanup failed' + : 'snapshot cleanup failed', + ) + } + if (outcome.status === 'rejected') throw outcome.error + return outcome.value } /** Drive one input step over the client connection. */ @@ -317,7 +294,7 @@ async function runStep( client: ClientSideConnection, step: InputStep, cwd: string, - waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, + waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, ): Promise { @@ -334,8 +311,10 @@ async function runStep( return } case 'newSessionExpectError': { - // The bridge rejects a session/new that widens the workspace scope (non-empty - // additionalDirectories / mcpServers — unimplemented). + // The bridge rejects a session/new that widens the workspace scope + // (non-empty additionalDirectories / mcpServers — unimplemented). The SDK + // surfaces that as a rejected RPC; swallow it so the run completes and the + // error frame is captured in the transcript. await client.newSession({ cwd, mcpServers: [], @@ -355,8 +334,10 @@ async function runStep( case 'promptExpectError': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession') - // The model fails this turn (a recorded provider error), so the bridge answers the prompt - // with a JSON-RPC error and the SDK rejects. + // The model fails this turn (a recorded provider error), so the bridge + // answers the prompt with a JSON-RPC error and the SDK rejects. That + // rejection IS the expected editor experience — swallow it so the run + // completes and the stdout transcript (the error frame) is captured. await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) .then(() => { throw new Error('snapshot-harness: expected the prompt to fail but it succeeded') }, () => { /* expected: the turn failed and the bridge returned an error */ }) @@ -365,12 +346,19 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // A hang fixture never resolves alone. Wait for its streamed chunk before cancellation - // so updates deterministically precede the cancelled prompt response. + // Dispatch without awaiting because the fixture does not settle on its + // own. Waiting for the selected update pins it before cancellation and + // the cancelled prompt response in the transcript. const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) - await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk') + const afterUpdate = step.afterUpdate ?? 'agent_message_chunk' + await waitForUpdate(u => u.sessionUpdate === afterUpdate) + // Arm this before cancellation so a fast tool drain cannot outrun the waiter. + const toolCallUpdateDone = step.waitForToolCallUpdate === undefined + ? undefined + : waitForUpdate(u => u.sessionUpdate === 'tool_call_update' && u.toolCallId === step.waitForToolCallUpdate) await client.cancel({ sessionId }) await promptDone + if (toolCallUpdateDone !== undefined) await toolCallUpdateDone return } case 'cancel': { @@ -402,16 +390,6 @@ async function runStep( } } -/** Resolve once the child process exits (any code/signal). */ -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { - // Race guard: both call sites run within one synchronous frame of - // stdin.end()/kill(), so the exit event cannot have been delivered yet; - // kept for any future caller that awaits in between. - /* v8 ignore next 1 -- unreachable race guard, see above */ - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no @@ -452,8 +430,14 @@ async function harvestSessionLogs(root: string): Promise { }) } } - // Match replay fixture assignment: primary first, then children by creation time, with id as - // a deterministic collision tiebreaker. + // Primary (no parentSession) first, then children by ascending createdAt. A + // scenario has exactly one top-level session. In the synchronous cut sibling + // children are created strictly sequentially, so their createdAt values are + // strictly ordered; the recordedId tiebreak only keeps a degenerate + // same-millisecond collision (unreachable here) deterministic. This harvest + // order must match the replay load order in dsh-llm-replay's loadSessionScripts + // so session..jsonl maps to the same child on record and replay — replay + // re-sorts childFiles by the same key, so the two stay consistent. logs.sort((a, b) => { const ap = a.parentSession === undefined ? 0 : 1 const bp = b.parentSession === undefined ? 0 : 1 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index bdf8cccaf7..b7267febe7 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,13 +1,23 @@ /** - * ACP snapshot suite kit: subprocess scenario harness, pure golden normalizers, and the Vitest - * suite factory behind `pnpm run test:snapshot`. Because this entry exports `suite.ts`, importing - * it requires a Vitest run. + * ACP snapshot suite kit — the shared machinery behind the keyless snapshot + * tier (`pnpm run test:snapshot`). Four layers, composable per example: the + * shared subprocess/client launcher ({@link launchAcpTestAgent}), the scripted + * scenario harness ({@link runScenario}), the pure golden normalizers + * ({@link normalizeStdout} / {@link normalizeSessionLog} / + * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite + * factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a + * full describe/it tree. Ordinary ACP e2e tests can use the launcher directly; + * an example's `*.snapshot.ts` supplies only its {@link AgentUnderTest} paths, + * snapshots directory, and {@link Scenario} table. + * + * NOTE: ./suite.ts imports vitest, so this package is importable only inside a + * vitest run — a support-tier constraint stated in the README. + * * @module @deepseek-ai/dsh-acp-snapshot */ export { runScenario, - type AgentUnderTest, type HarvestedLog, type InputScript, type InputStep, @@ -15,6 +25,12 @@ export { type RunOptions, type RunResult, } from './harness.ts' +export { + launchAcpTestAgent, + type AcpTestLaunchOptions, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from './launcher.ts' export { normalizeSessionLog, normalizeStdout, diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts new file mode 100644 index 0000000000..de5082eca4 --- /dev/null +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -0,0 +1,276 @@ +/** + * Shared launcher for ACP tests that drive an agent subprocess over JSON-RPC + * stdio. It owns source-or-built launch resolution, workspace environment, + * stdout tee, SDK client, update collection, permission fallback, and process + * shutdown so e2e and snapshot suites do not each reconstruct that boundary. + * + * @module @deepseek-ai/dsh-acp-snapshot/launcher + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' + +/** The source/built agent entry, leaf config, and workspace tsconfig an ACP test boots. */ +export interface AgentUnderTest { + /** The agent source bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */ + binScript: string + /** Explicit built-mode entry for fixtures whose source path is not under `src/`. */ + libBinScript?: string | undefined + /** The leaf `cordis.yml` loaded by the bin. */ + configPath: string + /** The repo tsconfig whose paths resolve unbuilt workspace imports. */ + tsconfigPath: string +} + +/** Options for one ACP test subprocess. */ +export interface AcpTestLaunchOptions { + /** The agent composition to boot. */ + agent: AgentUnderTest + /** Process cwd and default session-home root. */ + cwd: string + /** Alternate leaf config for this launch. */ + configPath?: string + /** Extra environment values layered over the parent environment. */ + env?: NodeJS.ProcessEnv + /** Permission handler; omitted requests fail closed as `cancelled`. */ + requestPermission?: (params: RequestPermissionRequest) => Promise +} + +/** A running ACP test process and its captured client-side surfaces. */ +export interface LaunchedAcpTestAgent { + /** The child process, exposed for process-level assertions. */ + child: ChildProcessWithoutNullStreams + /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ + spawned: Promise + /** The SDK connection backed by the child's stdio. */ + client: ClientSideConnection + /** Session updates in receive order. */ + updates: SessionNotification['update'][] + /** Decode all stdout bytes captured so far. */ + rawStdout(): string + /** Decode all stderr chunks captured so far. */ + stderr(): string + /** Resolve when a future session update matches the predicate. */ + waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise + /** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */ + close(signal?: NodeJS.Signals): Promise +} + +/** + * Boot an ACP agent subprocess and connect an SDK client to its stdio. + * + * @param options Agent paths, cwd, environment, and optional permission handler. + * @returns The running process, connected client, captures, and shutdown handle. + */ +export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent { + const { agent, cwd } = options + const launch = resolveExampleLaunch({ + srcBin: agent.binScript, + libBin: agent.libBinScript, + configArgs: ['--config', options.configPath ?? agent.configPath], + tsconfigPath: agent.tsconfigPath, + env: { + ...options.env, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) + const child = spawn( + launch.command, + launch.args, + { + cwd, + env: { ...process.env, ...launch.env }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + // A spawn-level failure is an asynchronous `error` event. Observe it in the + // same tick as spawn so a missing cwd or OS rejection cannot crash the test + // runner, then make startup and shutdown surface the original error. + // Keep observing after the first error: a fallback kill attempted during + // shutdown may itself report another process error, which must not become an + // unhandled EventEmitter error after the promise has already settled. + const childFailure = new Promise(resolve => child.on('error', resolve)) + const spawned = Promise.race([ + new Promise(resolve => child.once('spawn', resolve)), + childFailure.then((error): never => { throw error }), + ]) + // `spawned` is public and close() also awaits it, but a caller may ignore both. + // Keep that misuse from turning the already-observed child error into an + // unhandled promise rejection. + void spawned.catch(() => undefined) + + const stderrChunks: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderrChunks.push(chunk)) + + const rawBuffers: Buffer[] = [] + const passthrough = new Readable({ read() {} }) + const updates: SessionNotification['update'][] = [] + const updateWaiters: { + match: (update: SessionNotification['update']) => boolean + resolve: (update: SessionNotification['update']) => void + reject: (reason: unknown) => void + }[] = [] + let updateStreamFailure: Error | undefined + const closeUpdateStream = (): void => { + if (updateStreamFailure !== undefined) return + updateStreamFailure = new Error('ACP test agent update stream closed before a matching session update arrived') + for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure) + } + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => { + passthrough.push(null) + }) + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const inFlightClientCallbacks = new Set>() + const trackClientCallback = (callback: () => T | PromiseLike): Promise => { + const pending = Promise.resolve().then(callback) + inFlightClientCallbacks.add(pending) + const untrack = (): void => { inFlightClientCallbacks.delete(pending) } + void pending.then(untrack, untrack) + return pending + } + const requestPermission = options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } })) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + return trackClientCallback(() => { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue + updateWaiters.splice(index, 1) + waiter.resolve(params.update) + } + }) + }, + requestPermission: params => trackClientCallback(() => requestPermission(params)), + }) + const client = new ClientSideConnection(makeClient, stream) + // `exit` only reports the parent process's status. Descendants may retain + // inherited stdout/stderr handles and buffered ACP frames may still be + // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's + // `closed` follows parser exhaustion. Capture both eagerly so a caller that + // invokes close after process exit still joins the complete drain boundary. + const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + // The ACP SDK's readable loop dispatches client callbacks without awaiting + // them. Once `closed` settles no new callbacks can start, but callbacks + // already in flight still belong to this launch's teardown boundary. + while (inFlightClientCallbacks.size > 0) { + await Promise.allSettled([...inFlightClientCallbacks]) + } + }) + // A caller may await a pending update without calling close(). Make natural + // stream exhaustion terminal for those waiters too, but only after the + // parser has dispatched every buffered frame. + void client.closed.then(closeUpdateStream) + + return { + child, + spawned, + client, + updates, + rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), + stderr: () => stderrChunks.join(''), + waitForUpdate(match): Promise { + if (updateStreamFailure !== undefined) return Promise.reject(updateStreamFailure) + return new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })) + }, + async close(signal?: NodeJS.Signals): Promise { + try { + await spawned + } catch (error: unknown) { + await drained + closeUpdateStream() + throw error + } + if (!isRunning(child)) { + await drained + closeUpdateStream() + return + } + const exited = waitForExit(child) + if (signal === undefined) child.stdin.end() + else child.kill(signal) + const failure = await Promise.race([ + exited.then((): undefined => undefined), + childFailure, + ]) + if (failure === undefined) { + await drained + closeUpdateStream() + return + } + + // An `error` after spawn is not an exit edge: in particular, a failed + // signal can leave the subprocess live. Force termination, await the + // already-observed exit edge, and only then propagate the child error so + // callers may safely remove cwd/session resources after close rejects. + const fallbackError = Promise.withResolvers() + const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) } + child.once('error', observeFallbackError) + if (!child.kill('SIGKILL')) { + child.off('error', observeFallbackError) + closeUpdateStream() + throw new AggregateError( + [failure, new Error('Fallback SIGKILL was not accepted by the child process')], + 'ACP test agent failed and fallback termination was refused', + ) + } + const fallbackFailure = await Promise.race([ + exited.then((): undefined => undefined), + fallbackError.promise, + ]) + child.off('error', observeFallbackError) + if (fallbackFailure !== undefined) { + closeUpdateStream() + throw new AggregateError( + [failure, fallbackFailure], + 'ACP test agent failed and fallback termination was refused', + ) + } + await drained + closeUpdateStream() + throw failure + }, + } +} + +/** Resolve once a running child exits. */ +function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + return new Promise(resolve => child.once('exit', () => { resolve() })) +} + +/** Whether the child still lacks either OS termination marker. */ +function isRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null +} diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 322439bb01..938676e4b7 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -15,7 +15,7 @@ * @module @deepseek-ai/dsh-acp-snapshot/suite */ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -71,14 +71,6 @@ export interface Scenario { * false (replay derives from the fixture's `assistant/chunk` events). */ overridden?: boolean - /** - * How many SUBAGENT child sessions this scenario records beyond the top-level - * one (0 for a single-session scenario). Each child rides in a sibling fixture - * `session..jsonl` (1-based); replay forwards them to `dsh-llm-replay` so - * each child session replays from its own script, and record mode writes the - * harvested child logs back to those files. Defaults to 0. - */ - childSessions?: number /** * Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own * the prompt and tool schemas, while every classmate is checked for equality. @@ -129,14 +121,40 @@ export interface SnapshotSuiteOptions { } /** - * The sibling child-fixture paths for a scenario (`session.1.jsonl` …). + * Validate and order a scenario directory's session-fixture filenames. * - * @param dir The scenario's snapshots directory (`/`). - * @param childSessions How many subagent child sessions the scenario records. - * @returns One path per child, 1-based, in fixture order. + * The primary fixture is always `session.jsonl`; child sessions are discovered + * from contiguous `session.1.jsonl` … filenames. The directory is the source of + * truth, so scenario tables do not duplicate a child count that can drift from + * the files. A session-like JSONL with any other suffix fails loud. + * + * @param names File names in one scenario directory. + * @returns The primary and child fixture names in replay/harvest order. */ -export function childFixturePaths(dir: string, childSessions: number): string[] { - return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +export function sessionFixtureNames(names: readonly string[]): string[] { + if (!names.includes('session.jsonl')) throw new Error('missing session.jsonl') + const children: { name: string; index: number }[] = [] + for (const name of names) { + if (name === 'session.jsonl') continue + if (!name.startsWith('session.') || !name.endsWith('.jsonl')) continue + const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name) + if (match === null) throw new Error(`invalid child session fixture name: ${name}`) + children.push({ name, index: Number(match[1]) }) + } + children.sort((a, b) => a.index - b.index) + for (const [offset, child] of children.entries()) { + const expected = offset + 1 + if (child.index !== expected) { + throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`) + } + } + return ['session.jsonl', ...children.map(child => child.name)] +} + +/** Read one scenario directory's validated session-fixture inventory. */ +async function sessionFixtures(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + return sessionFixtureNames(entries.filter(entry => entry.isFile()).map(entry => entry.name)) } /** @@ -454,7 +472,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const childSessions = scenario.childSessions ?? 0 + // Replay/refresh need the committed inventory up front because those + // files drive the model scripts. Record mode creates that inventory + // from the harvested live logs, so it must also work for a brand-new + // scenario with no session.jsonl yet. + let fixtureFiles = RECORDING ? [] : await sessionFixtures(dir) + const childFixtureFiles = fixtureFiles.slice(1) const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -463,7 +486,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, + ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -491,7 +514,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const scrub = scenario.pinsHeader === true ? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log)) : scrubRequestHeaders - const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) : [] @@ -500,18 +522,37 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { || (REFRESHING && comparesLog) if (writesSessionFixtures) { expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0) - expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) - .toBe(childSessions + 1) + if (REFRESHING) { + expect(result.sessionLogs.length, `expected ${fixtureFiles.length} session logs (parent + children)`) + .toBe(fixtureFiles.length) + } + const outputFixtureFiles = [ + 'session.jsonl', + ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), + ] const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, 'session.jsonl'), scrub( + await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary, )) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, `session.${i}.jsonl`), scrub( + await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child, )) } + if (RECORDING) { + const outputNames = new Set(outputFixtureFiles) + const entries = await readdir(dir, { withFileTypes: true }) + await Promise.all(entries + .filter(entry => entry.isFile() + // Only valid numbered children are record-owned stale output. + // Malformed session-like names stay for the inventory guard to + // reject instead of being silently deleted during mutation. + && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) + && !outputNames.has(entry.name)) + .map(entry => rm(join(dir, entry.name)))) + fixtureFiles = outputFixtureFiles + } if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) @@ -540,7 +581,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // produce one without a model turn (a `rejected` turn carrying `hook/*`). if (comparesLog) { // The harvested logs (primary-first) must match their committed fixtures 1:1. - expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) + expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8')) @@ -619,9 +660,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(onDisk).toEqual(registered) }) - it('every registered scenario has its required fixture files', () => { - // Every scenario has an input script and an stdout golden. - for (const { name, overridden, childSessions, pinsHeader } of scenarios) { + it('every registered scenario has its required fixture files', async () => { + // Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars. + for (const { name, overridden, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) @@ -632,11 +673,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { .toBe(pinsHeader === true) expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``) .toBe(pinsHeader === true) - // A nested-agent scenario ships one child fixture per recorded subagent - // session (`session.1.jsonl` …), the replay source for that child session. - for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { - expect(existsSync(childFixture), childFixture).toBe(true) - } + await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) @@ -688,10 +725,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) - const files = [ - 'session.jsonl', - ...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`), - ] + const files = await sessionFixtures(dir) for (const file of files) { const fixture = await readFile(join(dir, file), 'utf8') expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`) diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index fccd1d6fcc..43b5ee3fb3 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { readdirSync } from 'node:fs' +import { spawn } from 'node:child_process' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' @@ -32,6 +33,10 @@ interface Behavior { rejectExtraDirs?: boolean /** How `session/prompt` settles: a clean response, a JSON-RPC error, or a hang until `session/cancel`. */ prompt?: 'respond' | 'error' | 'hang-until-cancel' + /** Emit a tool call instead of a message chunk before parking a cancellable prompt. */ + cancelAtToolCall?: boolean + /** Emit the parked tool call's terminal update after answering cancellation. */ + cancelToolCallUpdate?: boolean /** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */ permissionProbe?: boolean /** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */ @@ -40,6 +45,8 @@ interface Behavior { echoWorkspace?: boolean /** Write a line to stderr on boot (spec-side stderr-capture assertions). */ stderrNote?: string + /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ + lateInheritedOutput?: boolean /** Session logs to persist on stdin EOF. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ @@ -123,7 +130,23 @@ async function handlePrompt(id: number | string): Promise { params: { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mulling' } } }, }) } - chunk('thinking about it') + if (behavior.cancelAtToolCall === true) { + send({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call_fake_1', + title: 'fake tool', + kind: 'execute', + status: 'in_progress', + }, + }, + }) + } else { + chunk('thinking about it') + } if (behavior.echoEnv === true) { chunk(`env:${JSON.stringify({ mode: process.env.DSH_SNAPSHOT, @@ -226,6 +249,19 @@ function handleFrame(frame: Record): void { const parked = parkedPromptId parkedPromptId = null respond(parked, { stopReason: 'cancelled' }) + if (behavior.cancelToolCallUpdate === true) { + send({ + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'call_fake_1', + status: 'failed', + }, + }, + }) + } } return default: @@ -247,6 +283,24 @@ function flushLogsAndExit(): void { writeFileSync(join(sessionsRoot, 'bucket-noise', 'notes.txt'), 'not a session log\n') } if (behavior.deleteSessionsRoot === true) rmSync(sessionsRoot, { recursive: true, force: true }) + if (behavior.lateInheritedOutput === true) { + const frame = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'late inherited stdout' }, + }, + }, + }) + const code = [ + `setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`, + `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, + ].join(';') + spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref() + } process.exit(0) } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 1d953f5e95..3d174b3c3e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,13 +1,34 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' +import { launchAcpTestAgent } from '../src/launcher.ts' + +const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rm(...args: Parameters): Promise { + if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) { + const failure = fsControl.cleanupFailure + fsControl.cleanupFailure = undefined + await actual.rm(...args) + throw failure + } + await actual.rm(...args) + }, + } +}) /** * Unit tests for the subprocess harness, driven through the REAL spawn path - * (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in + * (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in * ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a * throwaway fixture path; the fake bin echoes observable facts (env, seeded * workspace, permission outcomes) into `agent_message_chunk` text, so the @@ -40,6 +61,181 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('surfaces an asynchronous child spawn failure through startup and close', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + let stdioClosed = false + let clientClosed = false + launched.child.once('close', () => { stdioClosed = true }) + void launched.client.closed.then( + () => { clientClosed = true }, + () => { clientClosed = true }, + ) + await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(stdioClosed).toBe(true) + expect(clientClosed).toBe(true) + }) + + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) + tempDirs.push(sessionsRoot) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + configPath: AGENT.configPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + const predicateFailure = new Error('predicate failed') + const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure }) + .catch((error: unknown): unknown => error) + await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(await failedPredicate).toBe(predicateFailure) + expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') + expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') + expect(launched.stderr()).toContain('launcher stderr') + const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/) + await launched.close() + await unmatched + await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/) + await launched.close('SIGKILL') + + // The minimal shape needs no environment or config override. + const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const childFailure = new Error('child process failed') + let exited = false + minimal.child.once('exit', () => { exited = true }) + minimal.child.emit('error', childFailure) + await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure) + // close rejects only after the fallback SIGKILL has produced an exit edge. + expect(exited).toBe(true) + }) + + it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true }) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const lateUpdate = launched.waitForUpdate(update => + update.sessionUpdate === 'agent_message_chunk' + && update.content.type === 'text' + && update.content.text === 'late inherited stdout') + + await launched.close() + + await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' }) + expect(launched.rawStdout()).toContain('late inherited stdout') + expect(launched.stderr()).toContain('late inherited stderr') + }) + + it('rejects promptly when fallback termination is refused', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [ + childFailure, + expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }), + ], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('rejects promptly when fallback termination emits an error', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure)) + return signal === 'SIGKILL' + }) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [childFailure, fallbackFailure], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true }) + let releasePermission: (() => void) | undefined + const permissionReleased = new Promise((resolve) => { releasePermission = resolve }) + let markPermissionStarted: (() => void) | undefined + const permissionStarted = new Promise((resolve) => { markPermissionStarted = resolve }) + let permissionFinished = false + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + async requestPermission() { + markPermissionStarted?.() + await permissionReleased + permissionFinished = true + return { outcome: { outcome: 'cancelled' } } + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined) + await permissionStarted + + const childClosed = once(launched.child, 'close') + let closeSettled = false + const closing = launched.close('SIGKILL').then(() => { closeSettled = true }) + await childClosed + await launched.client.closed + expect(closeSettled).toBe(false) + + releasePermission?.() + await closing + expect(permissionFinished).toBe(true) + }) + it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' }) await expect(runScenario( @@ -48,6 +244,23 @@ describe('runScenario', () => { )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/) }) + it('preserves launch-resolution errors when no child process exists', async () => { + const { dir, fixtureFile } = await scenario({}) + vi.stubEnv('DSH_EXAMPLE_MODE', 'lib') + try { + await expect(runScenario( + { steps: [] }, + { + agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined }, + mode: 'replay', + fixtureFile, + }, + )).rejects.toThrow(/expected a "\/src\/" segment/) + } finally { + vi.unstubAllEnvs() + } + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, @@ -121,6 +334,28 @@ describe('runScenario', () => { expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled')) }) + it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ + prompt: 'hang-until-cancel', + cancelAtToolCall: true, + cancelToolCallUpdate: true, + }) + const result = await runScenario( + { + steps: [...boot, { + op: 'promptAndCancel', + text: 'hang', + afterUpdate: 'tool_call', + waitForToolCallUpdate: 'call_fake_1', + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.rawStdout).toContain('"sessionUpdate":"tool_call"') + expect(result.rawStdout.indexOf('"sessionUpdate":"tool_call"')).toBeLessThan(result.rawStdout.indexOf('cancelled')) + expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"')) + }) + it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'error' }) const result = await runScenario( @@ -138,6 +373,39 @@ describe('runScenario', () => { )).rejects.toThrow(/expected the prompt to fail/) }) + it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'respond' }) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + const failures = (failure as AggregateError).errors as unknown[] + expect(failures).toHaveLength(2) + expect(failures[0]).toBeInstanceOf(Error) + expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/) + expect(failures[1]).toBe(cleanupFailure) + }) + + it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toBe('snapshot cleanup failed') + expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure]) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4cedc1cdfb..92e676b1d6 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +6,6 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { - childFixturePaths, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, @@ -16,6 +15,7 @@ import { normalizedToolSchemas, parseToolSchemasSnapshot, refreshFixtureReplacements, + sessionFixtureNames, restorePinnedToolSchemas, stabilizeRefreshLog, unknownToolCallIds, @@ -46,7 +46,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, - { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, + { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, @@ -54,7 +54,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ const RECORD_SCENARIOS: Scenario[] = [ { name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true }, - { name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'rec-child', hasModelTurn: true, recorded: true }, // recorded:false in record mode → registered but skipped (never re-recorded). { name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true }, ] @@ -64,7 +64,13 @@ const RECORD_SCENARIOS: Scenario[] = [ // committed record fixtures/goldens in place. const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1' const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-')) -if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true }) +if (!BOOTSTRAP) { + cpSync(RECORD_SRC, recordDir, { recursive: true }) + // Record mode owns its output inventory: a new scenario has no primary yet, + // while a changed child count can leave old numbered fixtures behind. + rmSync(join(recordDir, 'rec-pin', 'session.jsonl')) + writeFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'stale child\n') +} const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-')) cpSync(REPLAY_DIR, refreshDir, { recursive: true }) staleRefreshFixtures(refreshDir) @@ -140,6 +146,13 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { }) }) +describe('defineAcpSnapshotSuite: record inventory write-back', () => { + it('creates a missing primary fixture and prunes stale child fixtures', () => { + expect(readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8')).toContain('"type":"session"') + expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() + }) +}) + describe('defineAcpSnapshotSuite: registration contract', () => { it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { @@ -179,13 +192,41 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }) -describe('childFixturePaths', () => { - it('yields one sibling path per child, 1-based', () => { - expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl']) +describe('sessionFixtureNames', () => { + it('orders the primary and contiguous child fixtures while ignoring other files', () => { + expect(sessionFixtureNames([ + 'stdout.golden.jsonl', + 'session.2.jsonl', + 'session.jsonl', + 'session.1.jsonl', + 'input.json', + ])).toEqual(['session.jsonl', 'session.1.jsonl', 'session.2.jsonl']) }) - it('yields nothing for a single-session scenario', () => { - expect(childFixturePaths('/snap/s', 0)).toEqual([]) + it('accepts a primary-only scenario', () => { + expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl']) + }) + + it('rejects a directory without the primary fixture', () => { + expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing session.jsonl') + }) + + it('rejects gapped child fixtures', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl'])) + .toThrow('expected session.1.jsonl, found session.2.jsonl') + }) + + it.each(['session.0.jsonl', 'session.child.jsonl', 'session.01.jsonl'])( + 'rejects invalid child fixture name %s', + (name) => { + expect(() => sessionFixtureNames(['session.jsonl', name])) + .toThrow(`invalid child session fixture name: ${name}`) + }, + ) + + it('rejects duplicate child indexes', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl'])) + .toThrow('expected session.2.jsonl, found session.1.jsonl') }) }) diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index 350a8643e1..07a2db02ec 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -22,6 +22,10 @@ Tests of injection failures, partial topology, service load order, or service te None, as this test-only composition helper neither drives nor modifies model requests. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Only the mandatory prerequisite spine is shared** — adapters, optional plugins, `AgentLoop`, agents, and Context teardown remain caller-owned so scenario-specific ordering stays visible. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 661e073d13..bda9524bbe 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -56,6 +56,10 @@ A seeded or forked session arrives with events already in its log because constr None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped. diff --git a/packages/support/invariants/src/scoped-events.generated.ts b/packages/support/invariants/src/scoped-events.generated.ts index 06cca6bf55..36d1721945 100644 --- a/packages/support/invariants/src/scoped-events.generated.ts +++ b/packages/support/invariants/src/scoped-events.generated.ts @@ -30,10 +30,12 @@ const scopedSubjectResolvers = Object.freeze({ 'agent/created': adapt<'agent/created'>(args => args[0]), 'agent/disposed': adapt<'agent/disposed'>(args => args[0]), 'agent/error': adapt<'agent/error'>(args => args[0]), + 'agent/post-step': adapt<'agent/post-step'>(args => args[0]), 'agent/pre-step': adapt<'agent/pre-step'>(args => args[0]), 'agent/prompt-submit': adapt<'agent/prompt-submit'>(args => args[0]), 'agent/queued': adapt<'agent/queued'>(args => args[0]), 'agent/request': adapt<'agent/request'>(args => args[0]), + 'agent/request-error': adapt<'agent/request-error'>(args => args[0]), 'agent/session-prefix': adapt<'agent/session-prefix'>(args => args[0]), 'agent/session-start': adapt<'agent/session-start'>(args => args[0]), 'agent/status': adapt<'agent/status'>(args => args[0]), diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index d5bd2707ab..de0046c00e 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -814,7 +814,7 @@ describe('scoped-dispatch invariants', () => { ['agent/status', [agent, 'idle']], ['agent/queued', [agent, [], { source: { kind: 'user' }, steering: false }]], ['agent/session-start', [agent, 'startup']], - ['agent/pre-step', [agent, 1, 1, '', new AbortController().signal]], + ['agent/pre-step', [agent, 1, 1, new AbortController().signal]], ['agent/prompt-submit', [agent, [], { kind: 'user' }, () => Promise.resolve({ kind: 'allow' })]], ['agent/request', [agent, 1, 1, { model: 'm' }, () => Promise.resolve({ model: 'm' })]], ['agent/session-prefix', [agent, [], new AbortController().signal, () => Promise.resolve([])]], diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 3272f29b9a..4c8966333b 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -2,7 +2,7 @@ A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery. -Its consumer is the ACP snapshot harness in `examples/acp-agent`, which loads this plugin (via `cordis.snapshot.yml`) in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate). +Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. ## How the fixture works @@ -56,6 +56,10 @@ Named `name` / `inject` / `Config` / `apply`, with **no default export**: the co None, as this keyless test adapter sends no request to a provider model; it only replays recorded assistant chunks into the test loop. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 51e799114f..3299905a07 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { kind: 'hang' } /** One model exposed by a replay-only provider catalog. */ @@ -283,7 +283,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code, entry.status) + throw new LlmError(entry.message, entry.code) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index c9bd545b36..59ffc485db 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -265,12 +265,12 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -279,7 +279,7 @@ describe('installLlmReplay (through the real LlmService)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) expect(seen).toEqual(partial) }) @@ -384,7 +384,7 @@ describe('installLlmReplay (through the real LlmService)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 04f519b4c1..450f6f6f61 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -2,7 +2,7 @@ Shared subprocess harness for tests that boot an app and `cordis.yml` through the Cordis Loader. `resolveExampleLaunch` selects local `src` mode (tsx and root tsconfig paths) or CI `lib` mode (plain Node and package exports) from an explicit mode or `DSH_EXAMPLE_MODE`. -`runLoaderSmoke` owns the isolated cwd, DSH homes, stdin, diagnostics, deadline, termination, and cleanup. It returns both streams after a zero exit and rejects with both streams on failure. +`runLoaderSmoke` accepts bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup; it returns both streams after a zero exit and rejects with both streams on failure. This is support-tier test infrastructure, not product API. @@ -10,6 +10,10 @@ This is support-tier test infrastructure, not product API. None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request. +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index edbe39d8ca..36ab137f32 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -1,14 +1,12 @@ /** * Shared subprocess harness for keyless example smokes that boot a real - * `cordis.yml` through the stdio-agent bin and Cordis Loader. + * `cordis.yml` through an app bin and Cordis Loader. * * It also owns the mode-aware launch resolver every example subprocess harness shares * ({@link resolveExampleLaunch}): booting an example bin from TypeScript source under `tsx` (the * zero-build dev path, resolving `@deepseek-ai/dsh-*` / `@cordisjs/*` through the tsconfig `paths` * map) or from built `lib/` under plain Node (resolving bare packages through real `exports`, as an * installed consumer does, while Node type-strips relative example-local TypeScript plugins). - * Consolidating that spawn glue here retires the copies in the ACP snapshot harness and the example - * e2e drivers (the `TODO(acp-test-harness)`). * * @module @deepseek-ai/dsh-loader-smoke */ @@ -126,12 +124,14 @@ export interface LoaderSmokeOptions { readonly label: string /** Prefix for the isolated temporary process cwd. */ readonly tempDirPrefix: string - /** Absolute stdio-agent bin SOURCE path (`/src/bin.ts`); the `lib` bin is derived from it. */ + /** Absolute app-bin source path (`/src/bin.ts`); the `lib` bin is derived from it. */ readonly binScript: string /** Explicit plain-Node entry for `lib` mode; intended for test fixtures outside a package `src/` tree. */ readonly libBinScript?: string | undefined - /** Absolute real Loader config path. */ + /** Absolute real Loader config path, passed as the sole bin argument by default. */ readonly configPath: string + /** Complete argv after the bin path; overrides the default `[configPath]`. */ + readonly binArgs?: readonly string[] /** Absolute repo tsconfig path used for unbuilt workspace-package resolution (required in `src` mode). */ readonly tsconfigPath: string /** Boot from source via tsx (`src`) or built lib via plain Node (`lib`); defaults to the environment's mode. */ @@ -142,6 +142,10 @@ export interface LoaderSmokeOptions { readonly stdinLines?: readonly string[] /** Process deadline override for harness tests. */ readonly processTimeoutMs?: number + /** Optional world-state setup run in the isolated cwd before process start. */ + readonly prepare?: (cwd: string) => Promise | void + /** Optional world-state assertion run in the isolated cwd before cleanup. */ + readonly inspect?: (cwd: string) => Promise | void } /** Captured output from a Loader smoke that exited successfully. */ @@ -162,17 +166,18 @@ export interface LoaderSmokeResult { export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise { const cwd = await mkdtemp(join(tmpdir(), options.tempDirPrefix)) const processTimeoutMs = options.processTimeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS - const launch = resolveExampleLaunch({ - srcBin: options.binScript, - libBin: options.libBinScript, - configArgs: [options.configPath], - ...options.mode !== undefined ? { mode: options.mode } : {}, - tsconfigPath: options.tsconfigPath, - exposeInternals: true, - env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env }, - }) try { - return await new Promise((resolve, reject) => { + await options.prepare?.(cwd) + const launch = resolveExampleLaunch({ + srcBin: options.binScript, + libBin: options.libBinScript, + configArgs: options.binArgs ?? [options.configPath], + ...options.mode !== undefined ? { mode: options.mode } : {}, + tsconfigPath: options.tsconfigPath, + exposeInternals: true, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents'), ...options.env }, + }) + const result = await new Promise((resolve, reject) => { const child = spawn(launch.command, launch.args, { cwd, env: { ...process.env, ...launch.env }, @@ -217,6 +222,8 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise `${line}\n`).join('')) }) + await options.inspect?.(cwd) + return result } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/packages/support/loader-smoke/tests/fixtures/success.ts b/packages/support/loader-smoke/tests/fixtures/success.ts index fed57162e2..82a63cdfd3 100644 --- a/packages/support/loader-smoke/tests/fixtures/success.ts +++ b/packages/support/loader-smoke/tests/fixtures/success.ts @@ -6,6 +6,7 @@ process.stdin.on('data', (chunk: string) => { input += chunk }) process.stdin.on('end', () => { console.log(JSON.stringify({ configPath: process.argv[2], + args: process.argv.slice(2), cwd: process.cwd(), dshHome: process.env.DSH_HOME, agentsHome: process.env.DSH_AGENTS_HOME, diff --git a/packages/support/loader-smoke/tests/loader-smoke.spec.ts b/packages/support/loader-smoke/tests/loader-smoke.spec.ts index b99d810188..e8f6554691 100644 --- a/packages/support/loader-smoke/tests/loader-smoke.spec.ts +++ b/packages/support/loader-smoke/tests/loader-smoke.spec.ts @@ -1,4 +1,6 @@ import { existsSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' @@ -22,6 +24,7 @@ describe('runLoaderSmoke', () => { }) const output = JSON.parse(result.stdout) as { configPath: string + args: string[] cwd: string dshHome: string agentsHome: string @@ -30,6 +33,7 @@ describe('runLoaderSmoke', () => { } expect(output).toMatchObject({ configPath, + args: [configPath], marker: 'present', input: 'one\ntwo\n', }) @@ -39,6 +43,30 @@ describe('runLoaderSmoke', () => { expect(existsSync(output.cwd)).toBe(false) }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('passes an arbitrary bin argv and inspects world state before cleanup', async () => { + let inspected = '' + let marker = '' + const result = await runLoaderSmoke({ + label: 'argv fixture', + tempDirPrefix: 'loader-smoke-argv-', + binScript: fixture('success'), + libBinScript: fixture('success'), + configPath, + binArgs: ['--config', configPath, '--output-format', 'json', 'task with spaces'], + tsconfigPath, + prepare: cwd => writeFile(join(cwd, 'marker.txt'), 'prepared'), + inspect: async (cwd) => { + inspected = cwd + marker = await readFile(join(cwd, 'marker.txt'), 'utf8') + }, + }) + const output = JSON.parse(result.stdout) as { args: string[]; cwd: string } + expect(output.args).toEqual(['--config', configPath, '--output-format', 'json', 'task with spaces']) + expect(canonicalTempPath(inspected)).toBe(canonicalTempPath(output.cwd)) + expect(marker).toBe('prepared') + expect(existsSync(inspected)).toBe(false) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('rejects a non-zero exit with captured diagnostics', async () => { await expect(runLoaderSmoke({ label: 'failure fixture', diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md deleted file mode 100644 index a879433b67..0000000000 --- a/packages/support/subagent-mock/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# @deepseek-ai/dsh-subagent-mock - -A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md). - -It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the real Cordis loader/export path, exercising provider registration, async start, start-time capability validation, required-signal cancellation, `result`, `dispose`, and structured output deterministically and keylessly. - -## Usage - -Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional): - -| Key | Default | Meaning | -|---|---|---| -| `name` | `mock` | Registry name to register the provider under. | -| `reply` | `mock subagent reply` | The scripted child's final answer text. | -| `stopReason` | `completed` | The stop reason `result` settles with. | -| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`, `depthLimit`, `toolFilter`, and `persona`) the provider advertises. | -| `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. | -| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | - -Aborting the required request signal or disposing before `result` settles flips the stop reason to `aborted`, so both holder-facing cancellation paths are observable. - -## Model Experience - -Indirectly, through `dsh-tool-subagent`, which renders this test provider's configured reply or stop-reason error into the parent test history. - -## Known Limitations and Deferred Work - -- **Scripted provider only** — it does not run a model, create a child agent, or exercise real prompt/tool-loop behavior. -- **One synthetic outcome per run** — it models no multi-turn, streaming, steering, resume, or subprocess transport behavior. diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json deleted file mode 100644 index a4ed0a0c6e..0000000000 --- a/packages/support/subagent-mock/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-mock", - "description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts deleted file mode 100644 index 5032c92529..0000000000 --- a/packages/support/subagent-mock/src/index.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Scripted, model-free subagent provider for deterministic coverage of registration, - * capability checks, lifecycle, the model-facing tool, and structured results through the real - * loader path. It is a named-export functional plugin; no default export. - * @module @deepseek-ai/dsh-subagent-mock - */ - -import type { Context } from 'cordis' -import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { - SubagentCapabilities, - SubagentProvider, - SubagentResult, - SubagentRun, - SubagentStartRequest, - SubagentStopReason, -} from '@deepseek-ai/dsh-subagent' - -const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const - -const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } - -/** Scripted provider whose configured result aborts if disposed or signalled first. */ -class MockSubagentProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities - readonly inheritsParentContext: boolean - - constructor( - readonly name: string, - private readonly config: Config, - ) { - this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } - this.inheritsParentContext = config.inheritsParentContext ?? false - } - - async start(request: SubagentStartRequest): Promise { - if (request.signal.aborted) throw new Error('mock subagent start aborted before publication') - const reply = this.config.reply ?? 'mock subagent reply' - const output: ContentBlock[] = [{ type: 'text', text: reply }] - const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema - const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed' - const flags = { cancelled: false } - const onAbort = (): void => { flags.cancelled = true } - request.signal.addEventListener('abort', onAbort, { once: true }) - // Make publication genuinely asynchronous so a same-turn abort is still - // a provider-owned startup failure rather than a returned live run. - await Promise.resolve() - if (flags.cancelled) { - request.signal.removeEventListener('abort', onAbort) - throw new Error('mock subagent start aborted before publication') - } - - // A deterministic child id derived from the parent — no clock/random (both - // banned in deterministic paths here, and unnecessary for a scripted run). - const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`) - - const resultFor = (): SubagentResult => ({ - output, - ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, - stopReason: flags.cancelled ? 'aborted' : baseStop, - }) - - const result = new Promise((resolve) => { - setTimeout(() => { resolve(resultFor()) }, 0) - }).finally(() => { - request.signal.removeEventListener('abort', onAbort) - }) - return { - id, - result, - dispose(): Promise { - flags.cancelled = true - request.signal.removeEventListener('abort', onAbort) - return Promise.resolve() - }, - } - } -} - -export const name = 'subagent-mock' -export const inject = ['subagents'] - -/** Config for the mock provider; all optional with test-friendly defaults. */ -export interface Config { - /** Registry name to register under. */ - name: string - /** The text the scripted child "returns" as its final answer. */ - reply?: string - /** The stop reason the run settles with. */ - stopReason?: SubagentStopReason - /** Which start-time capabilities to advertise (default: all `true`). */ - capabilities?: Partial - /** - * The conversation-history descriptor to declare - * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh - * conversation). Set `true` to exercise seeded/fork wording in consumer - * tests. This flag says nothing about tool, service, scope, or authority - * inheritance. - */ - inheritsParentContext?: boolean - /** - * Structured value surfaced when a request carries an `outputSchema` and the - * `outputSchema` capability is on (default: `{ reply }`). - */ - structured?: unknown -} - -export const Config: z = z.object({ - name: z.string().default('mock'), - reply: z.string(), - stopReason: z.union(STOP_REASONS), - capabilities: z.object({ - outputSchema: z.boolean(), - depthLimit: z.boolean(), - toolFilter: z.boolean(), - persona: z.boolean(), - }), - inheritsParentContext: z.boolean(), - structured: z.any(), -}) - -export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config)) -} diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts deleted file mode 100644 index c9700a14b1..0000000000 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' -import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import * as mock from '../src/index.ts' - -/** A minimal parent — the mock provider only reads `parent.id`. */ -function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent -} - -function baseRequest(over: Partial = {}): SubagentStartRequest { - return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), signal: new AbortController().signal, ...over } -} - -async function mount(config: Partial = {}): Promise { - const ctx = new Context() - await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock', ...config }) - return ctx -} - -describe('dsh-subagent-mock', () => { - it('registers a provider on ctx.subagents and returns the scripted reply', async () => { - const ctx = await mount({ reply: 'hello from mock' }) - expect(ctx.subagents.list()).toEqual(['mock']) - - const run = await ctx.subagents.start('mock', baseRequest()) - await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'hello from mock' }], - structured: undefined, - stopReason: 'completed', - }) - await run.dispose() - }) - - it('registers under a configurable name', async () => { - const ctx = await mount({ name: 'spawn' }) - expect(ctx.subagents.list()).toEqual(['spawn']) - }) - - it('surfaces a structured result when the request carries an outputSchema', async () => { - const ctx = await mount({ reply: 'r', structured: { answer: 42 } }) - const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) - await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) - }) - - it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { - const ctx = await mount({ reply: 'fallback reply' }) - const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) - await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) - }) - - it('omits structured output when outputSchema capability is off', async () => { - const ctx = await mount({ capabilities: { outputSchema: false } }) - // The service rejects an outputSchema request against a no-cap provider, so - // the structured path is only reachable when the cap is on; with it off and - // no schema requested, the result has no structured field. - const run = await ctx.subagents.start('mock', baseRequest()) - const result = await run.result - expect(result).not.toHaveProperty('structured') - }) - - it('honors a configured stop reason', async () => { - const ctx = await mount({ stopReason: 'refusal' }) - const run = await ctx.subagents.start('mock', baseRequest()) - await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) - }) - - it('flips the stop reason to aborted when the signal fires before the result settles', async () => { - const ctx = await mount() - const controller = new AbortController() - const run = await ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) - controller.abort() - await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) - }) - - it('rejects an already-aborted request before starting publication', async () => { - const ctx = await mount() - const controller = new AbortController() - controller.abort() - - await expect(ctx.subagents.start('mock', baseRequest({ signal: controller.signal }))) - .rejects.toThrow('mock subagent start aborted before publication') - }) - - it('rejects when cancellation wins the asynchronous publication handoff', async () => { - const ctx = await mount() - const controller = new AbortController() - const pending = ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) - - controller.abort() - - await expect(pending).rejects.toThrow('mock subagent start aborted before publication') - }) - - it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(mock, { name: 'mock' }) - expect(ctx.subagents.list()).toEqual(['mock']) - await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) - }) - - it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // A default export would make Loader unwrap only that value and drop `inject`. - expect('default' in mock).toBe(false) - expect(mock.name).toBe('subagent-mock') - expect(mock.inject).toEqual(['subagents']) - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(mock) as Record - expect(unwrapped).toBe(mock) - expect(unwrapped.name).toBe('subagent-mock') - expect(unwrapped.inject).toEqual(['subagents']) - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index b061038714..afe458c711 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -26,6 +26,10 @@ See the [task type catalog](../../../docs/core-data-structures/tasks.md) and [ru Indirectly, through producer plugins and [`dsh-tool-tasks`](../tool-tasks/README.md), which render task ids, output, status, cancellation, and completion notices. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Tasks are process-local** — durable or cross-restart execution needs a separate lifecycle. diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index e9c896c3a1..457f5473a3 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -151,9 +151,9 @@ export class TaskService extends Service { * @returns fresh snapshots. */ list(caller?: Agent): TaskSnapshot[] { - const session = caller?.session.header.id + const session = caller?.id return [...this.store.values()] - .filter(task => task.owner === undefined || task.owner.session.header.id === session) + .filter(task => task.owner === undefined || task.owner.id === session) .map(task => this.snapshot(task)) } @@ -317,14 +317,14 @@ export class TaskService extends Service { * open, and a no-agent caller can never match an owned one). */ private assertAccess(task: TrackedTask, caller?: Agent): void { - if (task.owner !== undefined && task.owner.session.header.id !== caller?.session.header.id) { + if (task.owner !== undefined && task.owner.id !== caller?.id) { throw new Error(`task ${task.id} belongs to another session`) } } /** Project a fresh read-only snapshot from the mutable record. */ private snapshot(task: TrackedTask): TaskSnapshot { - const ownerSession = task.owner?.session.header.id + const ownerSession = task.owner?.id return { id: task.id, kind: task.kind, diff --git a/packages/tasks/tasks/tests/tasks.spec.ts b/packages/tasks/tasks/tests/tasks.spec.ts index f21eb9c433..0d3eae8338 100644 --- a/packages/tasks/tasks/tests/tasks.spec.ts +++ b/packages/tasks/tasks/tests/tasks.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' @@ -14,13 +14,13 @@ declare module '@deepseek-ai/dsh-tasks' { const agentScopeDisposers = new WeakMap Promise>() -function stubAgent(ctx: Context, rawId: string, rawSessionId = `${rawId}-session`): Agent { - const id = AgentId(rawId) +function stubAgent(ctx: Context, rawId: string): Agent { + const id = SessionId(rawId) const scopeFiber = ctx.plugin(() => {}) const agent = { id, options: {}, - session: new Session(SessionId(rawSessionId)), + session: new Session(id), status: 'idle' as const, ctx: scopeFiber.ctx, send() {}, @@ -453,11 +453,11 @@ describe('TaskService owner isolation', () => { it('rejects a stale owner instance after another agent reuses its id', async () => { const ctx = await harness() - const staleOwner = stubAgent(ctx, 'owner', 'stale-session') + const staleOwner = stubAgent(ctx, 'owner') const unregisterStale = ctx.agents.register(staleOwner) unregisterStale() - const currentOwner = stubAgent(ctx, 'owner', 'current-session') + const currentOwner = stubAgent(ctx, 'owner') ctx.agents.register(currentOwner) const current = producer({ owner: currentOwner }) ctx.tasks.start(current.spec) // Attach the current owner's cleanup first. @@ -467,7 +467,10 @@ describe('TaskService owner isolation', () => { expect(() => ctx.tasks.start({ ...stale.spec, run: staleRun })) .toThrow('is not the registered agent instance') expect(staleRun).not.toHaveBeenCalled() - expect(ctx.tasks.list(staleOwner)).toEqual([]) + // Access is keyed by the unified session id, so a reconnect carrying the + // same identity can observe the current task even though stale ownership + // registration is rejected by exact-instance validation. + expect(ctx.tasks.list(staleOwner)).toHaveLength(1) expect(ctx.tasks.list(currentOwner)).toHaveLength(1) current.settle({ status: 'completed' }) @@ -524,7 +527,7 @@ describe('TaskService owner cleanup', () => { it('does not let an old scope cleanup cancel a same-id/session replacement task', async () => { const ctx = await harness() - const oldOwner = stubAgent(ctx, 'owner', 'shared-session') + const oldOwner = stubAgent(ctx, 'owner') const detachOld = ctx.agents.register(oldOwner) const cancels: string[] = [] @@ -543,7 +546,7 @@ describe('TaskService owner cleanup', () => { start(oldOwner, 'old task') detachOld() - const replacement = stubAgent(ctx, 'owner', 'shared-session') + const replacement = stubAgent(ctx, 'owner') ctx.agents.register(replacement) const replacementId = start(replacement, 'replacement task') diff --git a/packages/tasks/tool-tasks/README.md b/packages/tasks/tool-tasks/README.md index 520a874df1..7e1ab82971 100644 --- a/packages/tasks/tool-tasks/README.md +++ b/packages/tasks/tool-tasks/README.md @@ -27,27 +27,51 @@ A default above the cap fails at load. ### System prompt -**What the model sees**: Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section. +#### What the model sees -**Token effect**: Small fixed input cost per request while active. +Every request in this plugin's registration scope contains this guidance. Agent-scoped tool filtering may hide the tools without removing the independently registered prompt section. -#### Background-task guidance +##### Background-task guidance ```markdown Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. ``` +#### Token effect + +Small fixed input cost per request while active. + +#### KV Cache effect + +Prefix-stable while the plugin scope and guidance text are unchanged. Activation or disposal may invalidate reuse from this prompt section. + ### Tool schemas -**What the model sees**: The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible. +#### What the model sees -**Token effect**: Fixed schema cost on each request where the tools are visible. +The generated [`task_output`, `task_list`, and `task_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-tasks) while this surface is visible. + +#### Token effect + +Fixed schema cost on each request where the tools are visible. + +#### KV Cache effect + +Prefix-stable while tool definitions and visibility are unchanged. Registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token. ### Results and notices -**What the model sees**: Reads return output or `(no new output)` followed by `[status: ]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task ` or the existing terminal status. Unreported owned completion uses the notice above. +#### What the model sees -**Token effect**: Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output. +Reads return output or `(no new output)` followed by `[status: ]` and optional detail. An empty list returns `(no background tasks)`. Kill returns `requested cancellation of task ` or the existing terminal status. Unreported owned completion uses the notice above. + +#### Token effect + +Results and notices remain in parent history until compaction. Stream reads do not repeat consumed output. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 1b5e221a3c..0a2d89431e 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import TaskService from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -23,17 +24,17 @@ async function setup(config: ToolTasks.Config = {}) { } /** - * A fake agent whose session token is `sessionId`, registered in `ctx.agents`. - * The agent id is deliberately different so session authorization and exact - * lifecycle ownership cannot be confused in tests. + * A fake agent with the shared agent/session identity, registered in + * `ctx.agents` with a dedicated lifecycle scope. */ function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: `agent-${sessionId}`, + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent agentRegistryDisposers.set(agent, ctx.agents.register(agent)) return agent @@ -276,7 +277,7 @@ describe('completion notices', () => { await tick() // Disposed owner: inject throws the disposed message — contained. - const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') }) + const inject = vi.fn(() => { throw new Error('agent "sess-1" is disposed') }) const owner = fakeAgent(ctx, 'sess-1', inject) const p = producer({ owner }) ctx.tasks.start(p.spec) @@ -287,7 +288,7 @@ describe('completion notices', () => { it('does not route an old owner completion notice to a same-session replacement', async () => { const { ctx } = await setup() - const oldInject = vi.fn(() => { throw new Error('agent "agent-shared" is disposed') }) + const oldInject = vi.fn(() => { throw new Error('agent "shared" is disposed') }) const oldOwner = fakeAgent(ctx, 'shared', oldInject) const p = producer({ owner: oldOwner }) ctx.tasks.start(p.spec) diff --git a/packages/timeout/timeout-policy/README.md b/packages/timeout/timeout-policy/README.md index 474185a4eb..bb73090ef7 100644 --- a/packages/timeout/timeout-policy/README.md +++ b/packages/timeout/timeout-policy/README.md @@ -37,9 +37,17 @@ Multiple `tools/execute` listeners compose by cordis registration order. Combine ### Conditional tool result -**What the model sees**: This plugin adds no prompt or schema. If a declared deadline wins, it replaces the provider's outcome with `Error: tool call timed out after ms` plus structured `TOOL_TIMEOUT`; otherwise the original result passes through unchanged. +#### What the model sees -**Token effect**: Zero tokens on non-timeout calls. A timeout adds one small retained error result and can prevent a larger late provider result from entering context. +This plugin adds no prompt or schema. If a declared deadline wins, it replaces the provider's outcome with `Error: tool call timed out after ms` plus structured `TOOL_TIMEOUT`; otherwise the original result passes through unchanged. + +#### Token effect + +Zero tokens on non-timeout calls. A timeout adds one small retained error result and can prevent a larger late provider result from entering context. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/todo/README.md b/packages/todo/README.md index b6a9ce2fc5..bfe5ec7503 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 0b07539f3d..45220706eb 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape @@ -28,15 +28,31 @@ A function/namespace plugin: it exports `name` / `inject` / `apply` and NO defau ### Tool schema -**What the model sees**: The model sees the generated [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo). +#### What the model sees -**Token effect**: Fixed schema cost on every request where the tool is visible. +The model sees the generated [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo). + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while the definition and visibility are unchanged. Plugin lifecycle or scoped restrictions may invalidate reuse from this schema. ### Tool-call history and result -**What the model sees**: Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: invalid todos: at most one task may be in_progress, got `, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. +#### What the model sees -**Token effect**: Token growth scales with every full list the model submits, and those call arguments remain until compaction. The result itself is small and fixed-shape. +Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: invalid todos: at most one task may be in_progress, got `, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. + +#### Token effect + +Token growth scales with every full list the model submits, and those call arguments remain until compaction. The result itself is small and fixed-shape. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 97c24a822e..869c1183f2 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -22,7 +22,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -57,7 +57,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Plan recorded.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) @@ -85,7 +85,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Done planning.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { provider: 'mock', model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { provider: 'mock', model: 'mock' }) agent.send([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index c2843cbcfe..2059bf13e8 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import * as tool from '../src/index.ts' /** @@ -20,7 +21,7 @@ import * as tool from '../src/index.ts' /** A parent Agent backed by a real Session — the tool reads `agent.session`. */ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { const session = new Session(SessionId(id)) - return { id: AgentId(id), session } as unknown as Agent & { session: Session } + return { id: SessionId(id), session } as unknown as Agent & { session: Session } } async function setup(): Promise { diff --git a/packages/ui/README.md b/packages/ui/README.md index 699a11bdb4..3dd26d80a8 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -9,12 +9,13 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) | +| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) | +| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 300cd69023..8642e33d6b 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -2,13 +2,13 @@ Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. -It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -37,7 +37,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: ## Multi-session -Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). +One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). ## Session config options @@ -94,33 +94,73 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa ### User messages -**What the model sees**: Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name= uri=]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted. +#### What the model sees -**Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. +Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name= uri=]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted. + +#### Token effect + +Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Human answers and permission decisions -**What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. +#### What the model sees -**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. +When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. + +#### Token effect + +Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Permission preset switches -**What the model sees**: `session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only. +#### What the model sees -**Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome. +`session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only. + +#### Token effect + +Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome. + +#### KV Cache effect + +The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history. ### Model switches -**What the model sees**: The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged. +#### What the model sees -**Token effect**: The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly. +The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged. + +#### Token effect + +The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly. + +#### KV Cache effect + +Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token. ### Loaded sessions -**What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message. +#### What the model sees -**Token effect**: Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none. +`session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message. + +#### Token effect + +Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none. + +#### KV Cache effect + +Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect. ## Known Limitations and Deferred Work diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index e8fb326d9b..e20cd40f10 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,8 +1,8 @@ /** - * Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes - * agents, routes their events, settles prompts by turn, and answers approvals. - * Each session keeps independent presentation and prompt-correlation state so - * concurrent streams cannot cross. Stdout is reserved for protocol frames. + * Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents, + * routes session-scoped events and approvals, and settles prompts by turn. + * Stdout is reserved for protocol frames. + * * @module @deepseek-ai/dsh-acp */ @@ -45,7 +45,6 @@ import { import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' @@ -76,16 +75,15 @@ import { } from './codec.ts' export const name = 'acp' -// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction. -// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`. -export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] +// Interface services back loading, presentation, interaction, and prompt assembly. +export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] -/** Build an ACP invalid-params error with visible human detail. */ +/** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */ +/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -210,7 +208,7 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** Runtime-only transport override for tests; production uses stdio. */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -246,13 +244,12 @@ interface ModelCatalogEntry { /** Per-session bridge state keyed by ACP session id. */ interface SessionRecord { - sessionId: SessionId agent: Agent - /** Owned-agent disposer that reaches per-session quiescence. */ + /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ dispose: () => Promise - /** Per-session tool presenter and in-flight call correlation. */ + /** Per-session tool presentation and call/result correlation. */ presenter: ToolPresenter - /** Session-creation snapshot of terminal-card support for call/result consistency. */ + /** Terminal capability snapshot shared by matching call and result updates. */ terminalEnabled: boolean /** Session-local provider/model selection and the current step snapshot. */ target: LlmTargetRef @@ -262,10 +259,7 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined - /** - * Idle config changes awaiting a turn-enclosed log anchor; last write wins. - * Responses overlay them, but a restart before anchoring restores the logged fold. - */ + /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -276,14 +270,15 @@ interface SessionRecord { * correlation in a `finally` so presentation failure cannot starve settlement. */ export function apply(ctx: Context, config: AcpConfig): void { - // Handlers run later outside this injection scope, so capture services now. + // ACP handlers execute outside this plugin's injection scope, so capture + // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools const userInteraction = ctx.userInteraction - // Presenter failures are logged and contained per session or replay. + // Presenter callbacks are contained so display failures cannot break protocol handling. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) /** Resolve a complete target only; partial config remains available to other request listeners. */ @@ -381,16 +376,12 @@ export function apply(ctx: Context, config: AcpConfig): void { } } - // TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map. - // Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together. - // Dropping the forward record lets the weak reverse entry expire. const sessions = new Map() - const bySession = new WeakMap() - // Reserve ids across asynchronous resume; distinct ids still load concurrently. + // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() - // Post-await checks prevent a closing bridge from publishing resumed sessions. + // Async creation checks this after awaits to avoid publishing after teardown. let closed = false - // Connection-level capability copied into each new session record. + // Each new or loaded session snapshots the latest connection capability. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -398,20 +389,26 @@ export function apply(ctx: Context, config: AcpConfig): void { // `notify` never observes it unset — no undefined guard needed. let conn: AgentSideConnection + /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ + const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const rec = sessions.get(agent.session.id) + return rec?.agent === agent ? rec : undefined + } + userInteraction.registerProvider({ async ask(request: AskUserQuestionRequest): Promise { if (request.agent === undefined) { throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT') } - const sessionId = bySession.get(request.agent) - if (sessionId === undefined) { + const rec = ownedRecord(request.agent) + if (rec === undefined) { throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION') } const answers: AskUserQuestionAnswerItem[] = [] for (const question of request.questions) { const options = question.options ?? [] const response = await withAbort(conn.unstable_createElicitation( - elicitationForQuestion(sessionId, question, options), + elicitationForQuestion(rec.agent.session.id, question, options), ), request.signal).catch((error: unknown) => { if (error instanceof UserInteractionError) throw error throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error }) @@ -506,13 +503,13 @@ export function apply(ctx: Context, config: AcpConfig): void { // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux - // strictly by session id: a `session/event` is routed to its own record, so - // two sessions streaming at once never cross-settle or interleave updates. + // strictly by session id: concurrent updates may alternate on the shared + // connection, but they retain the owning id and never cross-settle. ctx.on('session/event', (session, event: SessionEvent) => { const rec = sessions.get(session.header.id) if (rec === undefined) return try { - streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { + streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, { enabled: rec.terminalEnabled, cwd: session.header.cwd, }, { includeUserMessages: false }) @@ -543,12 +540,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // allow_always is a grant-storage design the approval RFC defers, so the // prompt never offers a durable grant the harness could not honor. ctx.on('approval/request', (req, next) => { - const sessionId = bySession.get(req.agent) + const rec = ownedRecord(req.agent) // The protocol requires `toolCall` (the prompt renders attached to it), so // a request without a callId has nothing to attach to — delegate. - if (sessionId === undefined || req.callId === undefined) return next() + if (rec === undefined || req.callId === undefined) return next() return conn.requestPermission({ - sessionId, + sessionId: rec.agent.session.id, toolCall: { toolCallId: req.callId }, options: [ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, @@ -577,26 +574,19 @@ export function apply(ctx: Context, config: AcpConfig): void { return [...options, { id: 'permission', name: 'Permissions', - description: 'Sets this session\'s sandbox and approval behavior.', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', category: 'mode', type: 'select', currentValue, options: [ ...presets.names.map((name: string) => presets.optionOf(name)), - // `custom` is offered only as the current-value echo, never as a target. + // `custom` echoes the current derived state but is never a target. ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], ], }] } - /** - * Whether the session's log currently has an open turn — the last boundary - * event is a `turn/start`. Decides whether a config switch may append NOW - * (enclosed) or must wait for the next prompt submission (see - * {@link SessionRecord.pendingSwitches}). Read from the LOG, not - * `agent.status`: status stays `running` across the gap between two queued - * turns, where a bare append would still land outside any turn. - */ + /** Whether the log has an open turn in which a config switch can be enclosed. */ const isTurnOpen = (agent: Agent): boolean => { const events = agent.session.events for (let index = events.length - 1; index >= 0; index -= 1) { @@ -607,29 +597,22 @@ export function apply(ctx: Context, config: AcpConfig): void { return false } - /** - * Anchor a pending preset in the open turn. `PermissionService.set()` skips - * net-zero changes, so the log records switches rather than select clicks. - */ + /** Anchor last-write-wins idle switches into a just-opened turn. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches rec.pendingSwitches = {} if (pending.preset === undefined) return const presets = ctx.get('permission') /* v8 ignore next -- a pending preset exists only if the service answered the - switch; a valid composition cannot unmount it before anchoring. */ + switch; it cannot unmount between that and the next turn in any composition. */ if (presets === undefined) return presets.set(rec.agent.session, pending.preset) } - // Anchor idle switches on the next prompt submission: its turn is open, but - // request assembly has not begun. This handler runs outside log emission, so - // invariants and persistence observe the events in log order; the first flush - // clears pending state. Promptless injection turns leave the switch pending, - // with no request or execution under stale settings. + // Prompt-submit is inside the new turn but before prompt assembly. Promptless + // injection turns leave the switch pending because they execute no request. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { - const sessionId = bySession.get(agent) - const rec = sessionId === undefined ? undefined : sessions.get(sessionId) + const rec = ownedRecord(agent) if (rec !== undefined) flushPendingSwitches(rec) return next() }) @@ -677,25 +660,20 @@ export function apply(ctx: Context, config: AcpConfig): void { const directory = modelDirectory(await readModelCatalog(), target.current) assertOpen() const handle = await agents.create({ - agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), setup: (agentCtx) => { installTarget(agentCtx, target) }, }) - // Creation awaits the unpublished setup transaction. A client disconnect - // can therefore close this bridge - // after the entry check but before the handle resolves; never install a - // post-close record that quiesce() could not have seen. + // Agent creation may resolve after the bridge closes; dispose the handle + // instead of publishing a record that teardown could not observe. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC immediately on close; real stdio may let the handler resume */ if (closed) { await handle.dispose() throw internalError('connection closed during session/new') } - bySession.set(handle.agent, sessionId) sessions.set(sessionId, { - sessionId, agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), @@ -753,7 +731,6 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined } const handle = await agents.resume({ - agentId: AgentId(sessionId), resumeSessionId: sessionId, agentOptions: agentOptions(config), setup: (agentCtx) => { installTarget(agentCtx, target) }, @@ -774,13 +751,11 @@ export function apply(ctx: Context, config: AcpConfig): void { } const directory = modelDirectory(catalog, target.current) const agent = handle.agent - bySession.set(agent, sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId, agent, dispose: () => handle.dispose(), presenter: makePresenter(agent), @@ -899,8 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (presets === undefined) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - // Clients may re-send the current selection on session start. Accept - // that echo without logging; this is the only valid `custom` request. + // A current-value echo is acknowledged without recording a switch. const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events) if (params.value === current) break if (!presets.names.includes(params.value)) { @@ -1083,7 +1057,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * zero or more times per event (best-effort UI feed, never load-bearing). * @param presenter - resolves tool-owned render intent for tool events; * defaults to the generic-fallback {@link nullToolPresenter}. - * @param terminal - the connection's terminal-rendering context; defaults to + * @param terminal - the session's terminal-rendering context; defaults to * disabled (the plain-text console-block fallback). * @param options - `includeUserMessages` (default `true`): live streaming * passes `false` so a prompt the client just sent is not echoed back. @@ -1142,16 +1116,16 @@ export function streamSessionEventUpdate( } /** - * Map a whole harness todo list to an ACP plan, assigning medium priority. - * Statuses map directly and ACP replaces its whole plan on each update. - * @param todos - the harness todo list (the whole list, not a diff). - * @returns the ACP plan body, one entry per todo. + * Map a whole harness todo list to an ACP replacement plan, using medium + * priority because harness todos do not carry one. + * @param todos - complete harness todo list. + * @returns one ACP plan entry per todo. */ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } } -/** Terminal-card capability and workspace context for event rendering. */ +/** Per-session terminal capability and workspace used while translating updates. */ export interface TerminalRendering { enabled: boolean /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */ @@ -1162,31 +1136,31 @@ export interface TerminalRendering { const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } /** - * Resolve tool-owned call/result views with generic fallbacks. Per-session - * call-id state supplies the tool name and arguments omitted from result events. - * Each entry is consumed by its result; any remainder dies with the session. + * Resolve tool-owned call/result views with a generic fallback. Per-session + * state correlates results with call arguments; interrupted calls may retain an + * entry only until that session's presenter is discarded. */ export class ToolPresenter { private readonly pending = new Map() /** - * @param tools the registry to resolve tool definitions by name. - * @param onError receives contained presenter failures before generic fallback. + * @param tools - registry used to resolve executing definitions. + * @param onError - contained presenter-error sink before generic fallback. + * @param agent - optional scoped registry view for the executing agent. */ constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, - /** Agent scope for tool lookup; absent during replay without a live agent. */ private readonly agent?: Agent, ) {} /** - * Resolve a pending call and remember its state for the matching result. + * Pending-state render intent for a `tool/call`; remembers `(name, args, card)` + * for the matching result. * @param callId - the call id the matching `tool/result` will look up. * @param name - the tool name, resolved against the registry for `presentCall`. - * @param argsJson - the raw arguments JSON from the event; parsed for the view - * (a non-JSON string is surfaced raw). - * @returns the tool-owned view, or a generic parsed-input fallback. + * @param argsJson - raw event arguments parsed for presentation. + * @returns the tool-owned view or generic fallback. */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) @@ -1198,22 +1172,20 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - // No tool-owned presentation: fall back to the tool name as the title, the - // full parsed args as the raw input, and kind `other` (the generic card). - // The kind is never sniffed from the name — the bridge does not special-case - // tool names; a tool that wants a richer kind declares `presentCall`. + // Tool names never imply presentation kind; richer cards are tool-owned. const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } this.pending.set(callId, { name, args, card: view.card }) return view } /** - * Resolve a completed result and consume its remembered call state. + * Completed-state render intent for a `tool/result`; consumes the remembered + * `(name, args, card)`. * @param callId - matching call id; unknown or late ids use raw content. - * @param content - the result's content blocks (the fallback and fill-in body). + * @param content - result content used by the fallback and fill-in body. * @param isError - whether the result is an error, forwarded to `presentResult`. * @param meta - the result's machine-readable meta, forwarded when present. - * @returns the normalized tool-owned view, or a raw-content generic fallback. + * @returns a normalized tool-owned view or raw-content fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) @@ -1283,11 +1255,11 @@ type AcpToolCallContent = | { type: 'diff'; path: string; oldText: string | null; newText: string } | { type: 'terminal'; terminalId: string } -/** Relativize an in-workspace file path in a card title; keep target paths raw. */ +/** Relativize only in-workspace title text; location and diff paths stay raw. */ function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // Reject an empty relative path or a leading parent-directory segment. + // Test the `..` segment, not a character prefix: `..cache/x` is in-workspace. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts index ed035aaf80..65679cd905 100644 --- a/packages/ui/acp/tests/approval.spec.ts +++ b/packages/ui/acp/tests/approval.spec.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The bridge's `approval/request` answerer: an ask for an agent the bridge @@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => { ): Promise<{ agent: Agent; request: ApprovalRequest }> { await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = h.ctx.agents.get(AgentId(sessionId)) + const agent = h.ctx.agents.get(SessionId(sessionId)) if (agent === undefined) throw new Error('newSession created no agent') // In production an ask always fires mid-turn (tool execution); open one so // request()'s turn-enclosure precondition holds for the direct drive below. @@ -88,9 +90,12 @@ describe('acp bridge — approval answerer', () => { await harness.ctx.plugin(ApprovalService) harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) - // Not created through the bridge: no bySession entry, so the answerer must - // call next() — nobody else answers, so the seam fails closed. - const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent + const { agent } = await ownedAgentRequest(harness) + // Even an impostor that claims the bridge-owned session id must delegate: + // ownership requires the exact Agent object stored in the session record. + const foreign = { + session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') })) .resolves.toBe('unavailable') expect(harness.permissionRequests).toHaveLength(0) diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index be05a09644..e7170c380f 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * End-to-end bridge specs over an in-memory transport: a real @@ -98,7 +98,7 @@ describe('acp bridge', () => { required: [], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}') @@ -127,7 +127,7 @@ describe('acp bridge', () => { required: ['custom'], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('apollo') }) @@ -136,7 +136,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const result = await harness.ctx.userInteraction.ask({ agent, @@ -167,7 +167,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -184,7 +184,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -201,11 +201,12 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) - await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] })) + const impostor = { session: { id: agent.session.id } } as typeof agent + await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] })) .rejects.toMatchObject({ code: 'NO_SESSION' }) harness.onElicitation = () => ({ action: 'cancel' }) @@ -225,7 +226,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const alreadyAborted = new AbortController() alreadyAborted.abort() @@ -265,8 +266,8 @@ describe('acp bridge', () => { expect(b.sessionId).toBeTruthy() expect(a.sessionId).not.toBe(b.sessionId) // Both agents are live and independently registered. - expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() - expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -281,7 +282,7 @@ describe('acp bridge', () => { const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) expect(res.sessionId).toBeTruthy() // The session header records that cwd, so its bash tools run there. - expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -321,7 +322,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/config-options.spec.ts b/packages/ui/acp/tests/config-options.spec.ts index 806aaa3ab6..3611c55e1f 100644 --- a/packages/ui/acp/tests/config-options.spec.ts +++ b/packages/ui/acp/tests/config-options.spec.ts @@ -29,7 +29,7 @@ function permissionOption(currentValue: string): object { return { id: 'permission', name: 'Permissions', - description: 'Sets this session\'s sandbox and approval behavior.', + description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.', category: 'mode', type: 'select', currentValue, diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 30faf9aafe..5cbdb6859b 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { @@ -17,25 +16,29 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Teardown must abort and await the loop: once it resolves the agent is settled, and the - // hanging prompt itself completes as cancelled rather than remaining pending. + // Dispose the whole context. The bridge's teardown must abort the agent and + // AWAIT whenIdle() — so right after dispose resolves, the agent is settled + // (not still running). Proves disposal waited, not just requested. await harness.ctx.fiber.dispose() expect(agent.status).not.toBe('running') + // The in-flight prompt settled (cancelled) rather than hanging forever. const res = await promptDone expect(res.stopReason).toBe('cancelled') }) it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => { - // Unload only the bridge while transport and shared services remain live. Its closed guard must - // reject late creation before an orphan agent can enter the registry. + // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop + // stay up and the transport is still live. A late session/new must hit the + // `closed` guard and reject — NOT create an agent the disposed bridge can no + // longer stream or settle. Verify the world: no agent appeared. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -47,21 +50,29 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => { - // The traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only - // disposal must therefore reclaim the agent even while agent-loop itself remains mounted. + // The factory (`ctx.agents.create`) is reached through the bridge's + // traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` + // registration binds to the CALLER context — the bridge fiber — not the + // AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload) + // must therefore reclaim the agent's registry entry, even though agents/ + // agent-loop stay up. This pins the fiber-ownership the bridge's teardown + // doc comment relies on; if a refactor rebinds the registration to the + // AgentLoop fiber, the agent would survive bridge dispose and this fails. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => { - // Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection - // shape—proves a late request did not create an undriveable agent. + // After teardown (here a client disconnect sets `closed`), a late + // `session/new` must NOT create an orphan agent the bridge can no longer + // drive/settle. The transport is gone so the RPC rejects; assert the world: + // no new agent appeared in the registry. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -73,43 +84,59 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { - // Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would - // be swallowed while a registered session survived without a client. + // The ACP transport closes (editor quits) while a turn runs. The bridge must + // settle the in-flight prompt cancelled and DISPOSE the agent (the session's + // per-agent AgentHandle teardown) rather than leaving an orphaned running — + // or even idled-but-still-registered — agent whose updates are swallowed. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! - // The transport will close before this hanging RPC settles. + const agent = harness.ctx.agents.get(SessionId(sessionId))! + // Start a prompt that hangs in the model stream. The prompt RPC will never + // return (its transport is severed), so do not await it. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // Sever the transport — the bridge's conn.closed teardown runs and drives the + // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() + // The agent's loop has stopped: status `disposed`. expect(agent.status).toBe('disposed') - // Await the same memoized bridge teardown without removing root services. It must finish the - // AgentHandle teardown and remove both registry records, not just stop the loop. + // Await the bridge teardown to completion WITHOUT tearing down the root + // agents/sessions services (so we can still query them). acpFiber.dispose() + // invokes the SAME memoized quiesce() the disconnect started and awaits its + // promise — which resolves only after every rec.dispose() (loop exit + + // session removal) has finished, closing the whenIdle()/owned.dispose() + // microtask race. The AgentHandle dispose has run: the agent is unregistered + // and its session removed from the store, not merely idled (the old + // behavior). The services live on the root ctx, so they survive this. await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { - // Transport close and fiber disposal can race. Both must await one memoized teardown; a guard - // based only on record removal could let the second caller return while the first still drains. + // conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously. + // They must share one teardown promise: dispose() must NOT return before the + // disconnect teardown's whenIdle() has settled (a `record === undefined`-only + // guard would let the second caller return early mid-teardown). const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // Fire both teardown paths without awaiting the first, then await both. const close = harness.closeClientTransport() const dispose = harness.ctx.fiber.dispose() await Promise.all([close, dispose]) + // After BOTH settle, the agent has fully drained (not still running). expect(agent.status).not.toBe('running') }) @@ -117,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const session = harness.ctx.agents.get(AgentId(sessionId))!.session + const session = harness.ctx.agents.get(SessionId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -129,18 +156,27 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { - // AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks, - // then detaches the session. Reloading verifies that order from durable state. + // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, + // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire + // through the still-attached store observer → `session/event`), and only + // THEN remove its publication hooks and session entry. If the order were inverted + // (detach first), the closing events would never reach persistence. Drive a + // CLEAN turn to completion, dispose JUST the bridge, then re-load the + // persisted log from disk and assert the closing turn/end is on disk — the + // world, not the agent's self-report. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length + const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) + // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + // Re-load the session from disk: every live event (incl. the closing + // turn/end) was flushed before the session was detached. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) expect(reloaded.events.length).toBe(liveEvents) const last = reloaded.events.at(-1)! @@ -149,20 +185,35 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { - // Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find - // that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last. + // The teardown-order contract only earns its keep when the closing events are + // produced BY the dispose itself. Here the model stream HANGS, so the turn is + // still open when teardown runs: the composite agent effect stops the loop, + // the loop unwinds and appends `turn/end {disposed}` + runs its final + // `session/flush` — all while the store-owned publication hooks are still attached (the session + // detach is the LAST disposer in the same effect's LIFO chain) — and only + // THEN is the session detached. If the order were inverted (or the session + // were a racing SIBLING effect), the abort-produced `turn/end` would never + // reach disk and a re-load would instead show crash-recovery's synthetic + // `interrupted` closer. Re-load from disk and assert the REAL `disposed` + // reason landed — proving the loop's own closing event was captured, not a + // recovered substitute. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') + // The turn is OPEN in the log (turn/start appended, no turn/end yet). const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length + // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered + // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not + // self-report) — NOT a crash-recovery `interrupted` substitute. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end') expect(persistedTurnEnds.length).toBe(openTurnEnds + 1) @@ -171,55 +222,71 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { - // A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully - // published, which guards against context-wide teardown. + // The factory returns a per-agent AgentHandle whose dispose() tears down + // EXACTLY that agent + its session — RFC 011 isolation. Create two agents + // directly through the registry factory (the same path the ACP bridge uses), + // dispose one handle, and assert the other survives, registered and + // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ - agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) const handleB = await harness.ctx.agents.create({ - agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' }, }) - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) await handleA.dispose() - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + // A is gone — unregistered AND its session removed from the store. + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + // B is wholly unaffected. + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() }) it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { - // Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or - // it would skip later session detach, leaking publication hooks and creating a durability hole. + // The AgentHandle teardown folds session-detach, register, and loop-stop + // into ONE composite effect whose disposers run as a `.then()` chain. The + // register disposer emits `agent/disposed`; if a listener throws and the + // emit is UNCONTAINED, the rejected chain skips the LATER session-detach + // disposer — stranding the session in the store with its publication hooks attached (a + // leak AND a durability hole, since the new design relies on detach + // running). The emit must be contained. Register a throwing listener, drive + // a clean turn, dispose, and assert the session was STILL removed. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() + // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() - expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { - // The Cordis effect disposer is single-shot and would let a second call return after its epoch - // clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence. + // The handle's dispose() must memoize: the underlying cordis effect disposer + // is single-shot, so a second dispose() while the first is mid-teardown would + // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the + // first call's await agent.done + final flush finished. Every caller must + // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' }, + sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' }, }) - // A hanging turn makes disposal produce a final flush; gate it so the second call arrives while - // teardown is observably in flight. + // Drive a turn that hangs in the model stream, so the loop is mid-turn when + // disposed — its exit runs a final session/flush we can gate to hold the + // teardown observably in-flight. handle.agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') @@ -227,21 +294,25 @@ describe('acp bridge — disposal & HMR safety', () => { const flushGate = new Promise((resolve) => { releaseFlush = resolve }) harness.ctx.on('session/flush', () => flushGate) + // First dispose enters teardown (aborts the hanging step) and blocks in the + // gated final flush. const first = handle.dispose() let firstSettled = false void first.then(() => { firstSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(firstSettled).toBe(false) + // Second dispose MUST await the same in-flight teardown, not resolve early. const second = handle.dispose() let secondSettled = false void second.then(() => { secondSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(secondSettled).toBe(false) // memoized: still pending with the first + // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) - expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined() await harness.dispose() }) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index de3a7a6f03..35dd54a634 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) + const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index f57767fb38..b8de5b8557 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -185,7 +184,7 @@ describe('acp bridge — session/load replay', () => { release() // resume() finishes AFTER teardown expect(await loadResult).toBe('rejected') // No live agent was installed for the closed connection. - expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -205,11 +204,11 @@ describe('acp bridge — session/load replay', () => { await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/cwd mismatch/) - expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -243,7 +242,7 @@ describe('acp bridge — session/load replay', () => { // Rejected BEFORE resume (metadata-only check) — no agent was registered, so // the id is not wedged: a later attempt hits the same clean rejection, not a // duplicate-registration error. - expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined() await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/no absolute persisted cwd/) }) diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index ca11934046..0881fe4199 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { @@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId - const agentA = harness.ctx.agents.get(AgentId(a))! - const agentB = harness.ctx.agents.get(AgentId(b))! + const agentA = harness.ctx.agents.get(SessionId(a))! + const agentB = harness.ctx.agents.get(SessionId(b))! // Wait deterministically for BOTH agents to enter `running` (not a fixed // sleep — agent startup latency is unbounded on a loaded worker). diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 78591ffa76..15c2415449 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -13,6 +12,7 @@ import { toolCallResponse, type BridgeHarness, } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { @@ -274,7 +274,7 @@ describe('acp bridge — turn outcomes', () => { // OWN turn with the real model answer. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle // inject writes turn/start{injection} → context/message → turn/end). Fire // once so it lands between install and the prompt turn. @@ -328,7 +328,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await agent.whenIdle() const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length expect(turnStarts).toBeLessThanOrEqual(1) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 5fed7b5023..852b1f9f9c 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -20,6 +20,10 @@ This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping. diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index c814919079..92a8d290bf 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,34 +1,42 @@ # @deepseek-ai/dsh-jsonrpc -Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../../examples/jsonrpc-demo/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design. +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair and demuxes `subagent/end` through the registry. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`. ## Config -No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`. +There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol -stdout carries only JSON-RPC frames. The loading config must omit stdout loggers; diagnostics go to stderr. +Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr. ## Shutdown and exit semantics -A `shutdown` request flushes its response, disposes the plugin fiber, then exits 0. Disposal idempotently shuts down every SDK-created agent to quiescence, detaches subscriptions, and closes the transport. Bare fiber disposal only stops serving; it does not exit. The app bin owns root disposal for stdin EOF (0), SIGTERM (0), and SIGINT (130). +The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process. ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. Each session permits one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and deployment persona remain in `cordis.yml`. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`. ## Model Experience ### SDK user message -**What the model sees**: For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`. +#### What the model sees -**Token effect**: Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens. +For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`. + +#### Token effect + +Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index bb91ab4fc2..e59fc4aaea 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 2b3646c731..8949ccb06a 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -1,8 +1,6 @@ /** - * JSON-RPC methods and notifications for SDK clients. Requests are - * `initialize`, repeated `session/prompt`, then `shutdown`; notifications carry - * durable session events, settled turns, and subagent lineage/outcomes. The - * external `cordis.yml` owns plugins, persistence, and the adapter set. + * JSON-RPC method and notification surface for out-of-process harness SDKs. + * The surrounding context owns plugins, persistence, and configured adapters. * * @module @deepseek-ai/dsh-jsonrpc/server */ @@ -10,14 +8,15 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' -/** One-time SDK initialization parameters. */ +/** Parameters for the process-wide SDK handshake. */ export interface InitializeParams { /** Working directory recorded on every SDK-created session's header. */ cwd: string @@ -27,16 +26,13 @@ export interface InitializeParams { model: string } -/** SDK handshake result. */ +/** Wire-stable server identity returned by initialization. */ export interface InitializeResult { /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ serverInfo: { name: string; version: string } } -/** - * Parameters of a `session/prompt` request: one user turn on one SDK session, - * with at most one in flight per session. - */ +/** One user turn on one SDK session. */ export interface SessionPromptParams { /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ sessionId: string @@ -44,7 +40,7 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Accepted prompt result; the outcome is reported by `session.finished`. */ +/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ export interface SessionPromptResult { /** Always `true`; the turn outcome is the paired `session.finished` notification. */ accepted: true @@ -56,16 +52,12 @@ interface SessionRecord { activePrompt: boolean } -interface SubagentRecord { - childSessionId: string - parentSessionId: string | undefined +/** Recover the delegating parent from the service-owned scoped carrier. */ +function subagentParentOf(carrier: Scoped): Agent { + return carrierKeyOf(carrier) as Agent } -/** - * SDK server over one booted harness context and transport peer. Construction - * subscribes to session, agent, and subagent lifecycle events until shutdown; - * reinitialization is unsupported. - */ +/** SDK server whose subscriptions and created agents live until {@link shutdown}. */ export class HarnessSdkServer { private cwd = process.cwd() private provider = 'deepseek' @@ -73,7 +65,6 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly subagentSessions = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -97,28 +88,17 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache lineage before child disposal removes the agent from the registry. - this.disposers.push(ctx.on('agent/created', (agent) => { - this.subagentSessions.set(String(agent.id), { - childSessionId: String(agent.session.id), - parentSessionId: agent.session.header.parentSession === undefined - ? undefined - : String(agent.session.header.parentSession), - }) - })) - this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { - const rec = this.subagentSessions.get(String(info.id)) - const agent = this.ctx.agents.get(info.id) - const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id)) - const parentSessionId = rec?.parentSessionId ?? ( - agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession) - ) - if (childSessionId === undefined) return - this.transport.notify('subagent.finished', { + this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { + const parent = subagentParentOf(this) + // This protocol reports only in-process child sessions. The service + // snapshots the provider's exact run provenance through child disposal; + // matching ids or parent lineage alone never establishes locality. + if (!info.local) return + transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), - ...(parentSessionId === undefined ? {} : { parentSessionId }), - childSessionId, + parentSessionId: String(parent.session.id), + childSessionId: String(info.id), status: info.stopReason === 'completed' ? 'ok' : 'error', stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), @@ -127,10 +107,9 @@ export class HarnessSdkServer { } /** - * Record cwd and provider/model, mounting the DeepSeek adapter only when the - * `deepseek` provider route has no configured owner. - * @param params - the SDK handshake parameters. - * @returns the server identity for the handshake. + * Configure the SDK route, mounting the DeepSeek fallback only when unowned. + * @param params - SDK handshake parameters. + * @returns server identity for the handshake. */ async initialize(params: InitializeParams): Promise { this.cwd = resolve(params.cwd) @@ -144,11 +123,9 @@ export class HarnessSdkServer { } /** - * Get or create the session agent, send the prompt, await quiescence, then - * notify `session.finished`. A session accepts one prompt at a time; other - * sessions remain independent. - * @param params - the target session id and prompt content. - * @returns `{ accepted: true }` after the turn settled. + * Run one prompt to settlement; overlap on the same session fails. + * @param params - target session and user content. + * @returns acceptance after the turn settled. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) @@ -171,9 +148,9 @@ export class HarnessSdkServer { } /** - * Dispose SDK-created agents to quiescence, unmount the server-mounted adapter, - * and detach subscriptions. The surrounding context remains running. - * @returns an empty object (the JSON-RPC result). + * Dispose server-owned agents, adapter, and subscriptions to quiescence. + * The surrounding context remains running. + * @returns empty JSON-RPC result. */ shutdown(): Promise> { this.shutdownTask ??= this.performShutdown() @@ -187,7 +164,6 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.subagentSessions.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { @@ -210,8 +186,8 @@ export class HarnessSdkServer { } /** - * Dispatch an incoming request; unknown methods throw for transport conversion - * to a JSON-RPC error response. + * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a + * JSON-RPC error response) on an unknown method. * @param method - the JSON-RPC method name. * @param params - the raw params object from the wire. * @returns the handler's result, to be serialized as the response. @@ -246,7 +222,6 @@ export class HarnessSdkServer { private async createSession(sessionId: string): Promise { const handle = await this.ctx.agents.create({ - agentId: AgentId(sessionId), sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, agentOptions: { provider: this.provider, model: this.model }, diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts new file mode 100644 index 0000000000..051b0eef20 --- /dev/null +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -0,0 +1,122 @@ +/** + * Built-artifact guard for the scope carrier shared by `dsh-subagent` and + * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must + * externalize `dsh-scope`; source-mode tests cannot expose an accidentally + * inlined second registry. This test runs the real `lib/index.js` bundles in a + * plain Node subprocess, disposes the child before settlement, and requires the + * SDK completion notification to retain the delegating parent. + */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const builtRuntimeProbe = String.raw` +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (path) => import(pathToFileURL(resolve(path)).href); +const [ + { Context }, + agentCore, + { default: SubagentService }, + { default: SessionPersistenceJsonl }, + { HarnessSdkServer }, + { SessionId }, +] = await Promise.all([ + load("vendor/cordis/lib/index.js"), + load("packages/examples/agent-spine-demo/lib/index.js"), + load("packages/subagent/subagent/lib/index.js"), + load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), + load("packages/ui/jsonrpc/lib/index.js"), + load("packages/core/session/lib/index.js"), +]); + +const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); +const ctx = new Context(); +try { + await ctx.plugin(agentCore, { workspaceContext: false }); + await ctx.plugin(SubagentService); + await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); + await new Promise((ready) => setTimeout(ready, 50)); + + const notifications = []; + const server = new HarnessSdkServer(ctx, { + request() { return Promise.reject(new Error("unexpected host request")); }, + notify(method, params) { notifications.push({ method, params }); }, + }); + const parent = await ctx.agents.create({ + sessionId: SessionId("built-parent"), + meta: { cwd: storageRoot }, + agentOptions: { model: "test" }, + }); + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId("built-child"), + meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, + agentOptions: { model: "test" }, + }); + const result = Promise.withResolvers(); + const unregister = ctx.subagents.registerProvider({ + name: "built-local", + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + return Promise.resolve({ + id: child.agent.id, + localAgent: child.agent, + result: result.promise, + dispose() { return Promise.resolve(); }, + }); + }, + }); + const run = await ctx.subagents.start("built-local", { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }); + await child.dispose(); + result.resolve({ output: [], stopReason: "completed" }); + await run.result; + await Promise.resolve(); + + console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); + await run.dispose(); + unregister(); + await parent.dispose(); + await server.shutdown(); +} finally { + await ctx.fiber.dispose(); + await rm(storageRoot, { recursive: true, force: true }); +} +` + +describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { + it('preserves parent-scoped completion after child disposal', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { + cwd: repoRoot, + timeout: 15_000, + }) + + expect(stderr).not.toContain('listener threw') + expect(JSON.parse(stdout) as unknown).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'built-local', + agentId: 'built-child', + parentSessionId: 'built-parent', + childSessionId: 'built-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [], + }, + }]) + }) +}) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 22f01178ec..5b9259dd85 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,12 +5,13 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -66,7 +67,13 @@ async function makeHarness(storageDir: string) { } /** Drive the owning service so test lifecycle events carry the real parent scope. */ -async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise { +async function settleSubagent( + ctx: Context, + parent: Agent, + info: Omit & { localAgent: Agent | undefined }, + beforeSettle?: () => Promise, +): Promise { + const result = Promise.withResolvers() const disposeProvider = ctx.subagents.registerProvider({ name: info.provider, capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -74,9 +81,8 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI async start() { return { id: info.id, - result: info.lastAssistantMessage === undefined - ? Promise.reject(new Error('synthetic infrastructure failure')) - : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }), + localAgent: info.localAgent, + result: result.promise, dispose: () => Promise.resolve(), } }, @@ -87,6 +93,12 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI prompt: [], signal: new AbortController().signal, }) + await beforeSettle?.() + if (info.lastAssistantMessage === undefined) { + result.reject(new Error('synthetic infrastructure failure')) + } else { + result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }) + } await run.result.then(() => undefined, () => undefined) await run.dispose() } finally { @@ -136,7 +148,6 @@ describe('HarnessSdkServer', () => { expect(llmServer.requests).toHaveLength(2) const orphanHandle = await ctx.agents.create({ - agentId: AgentId('orphan-agent'), sessionId: SessionId('orphan-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'dsagent-model' }, @@ -171,8 +182,8 @@ describe('HarnessSdkServer', () => { } as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } - const create = vi.fn(async (options: { agentId: AgentId }) => - String(options.agentId) === 'main' ? mainHandle : otherHandle) + const create = vi.fn(async (options: { sessionId: SessionId }) => + String(options.sessionId) === 'main' ? mainHandle : otherHandle) const ctx = { on: vi.fn(() => () => undefined), agents: { create, get: () => undefined }, @@ -264,29 +275,42 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, transport) const parentHandle = await ctx.agents.create({ - agentId: AgentId('parent-agent'), sessionId: SessionId('main'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + // A custom in-process provider may own its child at the provider/root + // scope while preserving durable parent lineage. const handle = await ctx.agents.create({ - agentId: AgentId('child-agent'), sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + expect(ctx.agents.roots()).toContain(handle.agent) + const parentlessHandle = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('parentless-child-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', - id: AgentId('child-agent'), + id: SessionId('child-session'), + localAgent: handle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], - }) + }, () => handle.dispose()) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'spawn', + id: SessionId('parentless-child-session'), + localAgent: parentlessHandle.agent, + stopReason: 'error', + }, () => parentlessHandle.dispose()) expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { provider: 'spawn', - agentId: 'child-agent', + agentId: 'child-session', parentSessionId: 'main', childSessionId: 'child-session', status: 'ok', @@ -294,8 +318,18 @@ describe('HarnessSdkServer', () => { lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'parentless-child-session', + parentSessionId: 'main', + childSessionId: 'parentless-child-session', + status: 'error', + stopReason: 'error', + }, + }) - await handle.dispose() await parentHandle.dispose() await server.shutdown() } finally { @@ -304,7 +338,282 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live agent lineage for uncached subagent end events', async () => { + it('ignores a remote run id that collides with a local child of the same parent', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('collision-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const collidingChild = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('remote-run-id'), + meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'remote', + id: SessionId('remote-run-id'), + localAgent: undefined, + stopReason: 'completed', + lastAssistantMessage: [], + }) + + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.agentId === 'remote-run-id', + )).toBe(false) + + await collidingChild.dispose() + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('retains locality across continuation runs on one live child', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('continuation-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const childHandle = await parentHandle.agent.ctx.agents.create({ + sessionId: SessionId('continuation-child'), + meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + localAgent: childHandle.agent, + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'first' }], + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + localAgent: childHandle.agent, + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'second' }], + }, () => childHandle.dispose()) + + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'continuation-child', + )).toHaveLength(2) + + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('correlates reused local ids by parent scope when runs settle out of order', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const oldParent = await ctx.agents.create({ + sessionId: SessionId('old-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const oldChild = await oldParent.agent.ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const first = Promise.withResolvers() + const sameLifetime = Promise.withResolvers() + const replacement = Promise.withResolvers() + const results = [first.promise, sameLifetime.promise, replacement.promise] + let starts = 0 + let currentLocalAgent = oldChild.agent + const disposeProvider = ctx.subagents.registerProvider({ + name: 'reused', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + const result = results[starts] + starts += 1 + if (result === undefined) throw new Error('unexpected fourth reused-id run') + return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() }) + }, + }) + + const firstRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + const sameLifetimeRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' }) + await sameLifetimeRun.result + await oldChild.dispose() + const newParent = await ctx.agents.create({ + sessionId: SessionId('new-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const newChild = await newParent.agent.ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, + agentOptions: { model: 'deepseek' }, + }) + currentLocalAgent = newChild.agent + const secondRun = await ctx.subagents.start('reused', { + parent: newParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' }) + await secondRun.result + first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' }) + await firstRun.result + await Promise.resolve() + + const finished = transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'reused-child', + ) + expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([ + [{ type: 'text', text: 'same lifetime' }], + [{ type: 'text', text: 'new lifetime' }], + [{ type: 'text', text: 'old lifetime' }], + ]) + expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([ + 'old-parent', + 'new-parent', + 'old-parent', + ]) + + await firstRun.dispose() + await sameLifetimeRun.dispose() + await secondRun.dispose() + disposeProvider() + await newChild.dispose() + await oldParent.dispose() + await newParent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('keeps locality bound to the accepted run across provider re-registration', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parent = await ctx.agents.create({ + sessionId: SessionId('provider-reuse-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('provider-reuse-child'), + meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const localResult = Promise.withResolvers() + const remoteResult = Promise.withResolvers() + const unregisterLocal = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: child.agent, + result: localResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const localRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + unregisterLocal() + + const unregisterRemote = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: undefined, + result: remoteResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const remoteRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' }) + await remoteRun.result + await Promise.resolve() + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.lastAssistantMessage !== undefined, + )).toBe(false) + + await child.dispose() + localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' }) + await localRun.result + await Promise.resolve() + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'provider-reuse-child', + )).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'reused-provider', + agentId: 'provider-reuse-child', + parentSessionId: 'provider-reuse-parent', + childSessionId: 'provider-reuse-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'local' }], + }, + }]) + + await localRun.dispose() + await remoteRun.dispose() + unregisterRemote() + await parent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('uses explicit local provenance when start was missed and ignores remote runs', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -312,40 +621,67 @@ describe('HarnessSdkServer', () => { let failedHandle: AgentHandle | undefined try { parentHandle = await ctx.agents.create({ - agentId: AgentId('fallback-parent-agent'), sessionId: SessionId('fallback-parent'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) - handle = await ctx.agents.create({ - agentId: AgentId('fallback-child-agent'), + handle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) - failedHandle = await ctx.agents.create({ - agentId: AgentId('failed-child-agent'), + const fallbackChild = handle.agent + failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + const missedStartResult = Promise.withResolvers() + const disposeMissedStartProvider = ctx.subagents.registerProvider({ + name: 'fork', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: true, + start: () => Promise.resolve({ + id: SessionId('fallback-child-session'), + localAgent: fallbackChild, + result: missedStartResult.promise, + dispose: () => Promise.resolve(), + }), + }) + // Start before the server subscribes. The terminal payload still carries + // this run's exact local child without reconstructing it from ids. + const missedStartRun = await ctx.subagents.start('fork', { + parent: parentHandle.agent, + prompt: [], + signal: new AbortController().signal, + }) const transport = new FakeTransport() const server = new HarnessSdkServer(ctx, transport) + missedStartResult.resolve({ output: [], stopReason: 'max-tokens' }) + await missedStartRun.result + await Promise.resolve() + await missedStartRun.dispose() + disposeMissedStartProvider() + // The server also missed this agent's creation but sees the exact child + // on the run lifecycle payload. await settleSubagent(ctx, parentHandle.agent, { - provider: 'fork', - id: AgentId('fallback-child-agent'), - stopReason: 'max-tokens', + provider: 'fork-live-fallback', + id: SessionId('fallback-child-session'), + localAgent: fallbackChild, + stopReason: 'completed', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('failed-child-agent'), + id: SessionId('failed-child-session'), + localAgent: failedHandle.agent, stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('missing-child-agent'), + id: SessionId('missing-child-agent'), + localAgent: undefined, stopReason: 'error', }) @@ -353,7 +689,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'fallback-child-agent', + agentId: 'fallback-child-session', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', status: 'error', @@ -365,7 +701,8 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'failed-child-agent', + agentId: 'failed-child-session', + parentSessionId: 'fallback-parent', childSessionId: 'failed-child-session', status: 'error', stopReason: 'error', @@ -569,6 +906,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(4) + expect(on).toHaveBeenCalledTimes(3) }) }) diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index bd37890dd1..e5ee51b69d 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -10,6 +10,10 @@ The service requires a confining `ctx.bash` executor and `ctx.approval`. A table Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..eda7d00b8b 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -9,34 +9,50 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | Key | Default | Meaning | |---|---|---| | `welcome` | `ready.` | Banner printed before the first prompt | -| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | +| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. +The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects. ```yaml - id: stdio name: '@deepseek-ai/dsh-stdio' config: welcome: 'agent REPL ready. Give it a coding task.' - agent: main + sessionId: main ``` ## Model Experience ### Readline prompt input -**What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. +#### What the model sees -**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. +Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. + +#### Token effect + +Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ### Terminal user-interaction answers -**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. +#### What the model sees -**Token effect**: Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. +When a consumer calls `ctx.userInteraction.ask()`, this provider renders the question in the terminal and returns selected option labels or `custom` text. Through `dsh-tool-ask-user`, closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`; disposal or abort becomes `Error: ask_user_question was interrupted before the user answered`. + +#### Token effect + +Waiting and terminal prompts add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. ## Known Limitations and Deferred Work -- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label. +- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. - **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. - **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index 3b00dc6625..e1bffdf171 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -23,10 +23,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-loop": { + "optional": true + } }, "dependencies": { "schemastery": "^3.18.0" @@ -34,9 +40,10 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.6" + "cordis": "^4.0.0-rc.7" } } diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..6f73d948bf 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -1,7 +1,8 @@ /** * The stdio app's readline UI: reads lines from stdin into `agent.send()` or - * `steer()`, renders the durable event stream to stdout, and exits piped input - * only after submitted work reaches idle. + * `steer()`, renders the durable event stream to stdout, buffers startup input + * for one exact agent/session identity, and exits piped input only after + * submitted work reaches idle. * * This package is the independently composable stdio front door. It establishes * the terminal channel and drives an agent created or resumed by app or @@ -13,7 +14,9 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-loop' +import { SessionId } from '@deepseek-ai/dsh-session' import { UserInteractionError, type AskUserQuestionAnswer, @@ -30,13 +33,13 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string + /** Exact shared agent/session identity stdin drives. Defaults to `'main'`. */ + sessionId?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - agent: z.string().default('main'), + sessionId: z.string().default('main'), }) /** @@ -59,6 +62,15 @@ function isTTYPair(input: Readable, output: Writable): boolean { return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) } +/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + interface PendingQuestion { request: AskUserQuestionRequest questionIndex: number @@ -74,10 +86,15 @@ type OptionSelection = | { kind: 'invalid' } /** - * Register stdio chat against an injectable I/O runtime. - * @param ctx - agent and event context. - * @param config - plugin config, defaulted for direct callers. - * @param runtime - line source, render sink, and exit hook. + * The plugin body, parameterized over its I/O runtime. `apply` is the thin + * production wrapper that binds the real `process` streams; tests call this + * directly with fakes. Returns nothing — all registration is via `ctx.on`/ + * `ctx.effect`, so fiber disposal tears every listener and the readline + * interface down. + * @param ctx - the context supplying the `agents` service and the event feeds. + * @param config - the plugin config; defaults are re-applied here for direct + * callers that bypass Loader validation. + * @param runtime - the process-I/O seam (line source, render sink, exit hook). */ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void { // Default here too (not just via schemastery's `.default()`): this helper is @@ -85,18 +102,22 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Loader validation, so it must be self-contained rather than trusting the // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. const welcome = config.welcome ?? 'ready.' - const agentId = AgentId(config.agent ?? 'main') + const sessionId = SessionId(config.sessionId ?? 'main') const { input, output, exit } = runtime - // Session ids need not equal agent ids. Seed existing agents before listening - // so a pre-created or HMR-surviving agent still gets its short render label. - const labelBySession = new Map() - for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) - ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) - ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + // Bind only to the exact identity this app passed to its config-created + // agent. Session ids are opaque: neither a prefix nor registry order can + // identify ownership. The root check rejects a child that somehow preempts + // the configured id; later recreation under the same id supports loop HMR. + const matchesConfiguredIdentity = (agent: Agent): boolean => + agent.id === sessionId && ctx.agents.roots().includes(agent) + let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === sessionId) - // Render the canonical append order from session/event so reasoning state is - // deterministic across chunks and boundaries; there are no agent/* mirrors. + // Transcript rendering off the durable `session/event` feed — the assistant + // token stream, turn/step boundaries, tool activity, and todos all come from + // the one canonical stream (no agent/* mirrors). A single listener over the + // append order keeps `inReasoning` transitions deterministic across chunk and + // boundary events. let inReasoning = false ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { @@ -112,7 +133,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt output.write(chunk.text) } } else if (event.type === 'turn/start') { - const label = labelBySession.get(session.header.id) ?? session.header.id + const label = target?.session === session ? 'main' : session.id output.write(`\n[${label} turn ${event.data.turn}] `) } else if (event.type === 'turn/end') { if (inReasoning) output.write('\x1B[0m') @@ -138,10 +159,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }) ctx.effect(() => { - const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - // On piped EOF, exit immediately if no work was submitted. Otherwise wait - // for a real running state followed by idle: sends do not synchronously mark - // running, and several queued lines may share one turn. + // Piped-input exit, once stdin reaches EOF: + // - If no line ever submitted work (empty stdin, blank-only lines), exit + // immediately — no turn will ever start, so there is nothing to wait + // for. (Gating on an observed 'running' here would hang forever.) + // - If work WAS submitted, exit the next time the agent settles to idle + // AFTER having run. Two subtleties this handles: the loop batches + // several queued messages into ONE turn (one idle), so we don't count + // sends; and agent.send() does NOT synchronously flip status to + // 'running', so requiring an observed 'running' first (`sawRunning`) + // avoids exiting in the gap before the turn starts and dropping work. let stdinClosed = false let disposed = false let submittedWork = false @@ -149,6 +176,38 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt let exitTimer: ReturnType | undefined let activeQuestion: PendingQuestion | undefined const questionQueue: PendingQuestion[] = [] + const queuedInput: string[] = [] + let targetReady = target !== undefined + let hadReadyTarget = targetReady + let failedStartup: { error: unknown } | undefined + + const submit = (agent: Agent, text: string): void => { + submittedWork = true + if (agent.status === 'running') { + agent.steer([{ type: 'text', text }]) + } else { + agent.send([{ type: 'text', text }]) + } + } + + const disposeCreatedListener = ctx.on('agent/created', (agent) => { + if (!matchesConfiguredIdentity(agent)) return + target = agent + targetReady = false + failedStartup = undefined + }) + const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { + if (agent !== target) return + targetReady = true + hadReadyTarget = true + for (const text of queuedInput.splice(0)) submit(agent, text) + }) + const disposeDisposedListener = ctx.on('agent/disposed', (agent) => { + if (target !== agent) return + target = undefined + targetReady = false + }) + const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) const maybeExit = (): void => { if (disposed || !stdinClosed) return @@ -156,19 +215,33 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Work submitted: wait until a turn has run and the agent is idle. if (submittedWork) { if (!sawRunning) return - const agent = ctx.agents.get(agentId) + const agent = target if (agent && agent.status !== 'idle') return // a turn is still running } - // Let final output flush; track the timer so re-entry coalesces and HMR - // disposal can cancel it before it exits the replacement process. + // Let any final output flush, then exit. The handle is tracked so the + // disposer can cancel it — a dispose within the flush window must not let + // the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g. + // repeated idle signals) coalesce onto the one pending timer. if (exitTimer !== undefined) { return // exit already scheduled — coalesce re-entrant calls } exitTimer = setTimeout(() => { exit(0) }, 200) } + const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (failedSessionId, error) => { + if (failedSessionId !== sessionId || targetReady) return + failedStartup = { error } + const dropped = queuedInput.length + queuedInput.length = 0 + submittedWork = sawRunning + if (dropped > 0) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`) + } + maybeExit() + }) + const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject.id !== agentId) return + if (subject !== target) return if (status === 'running') sawRunning = true if (status === 'idle') maybeExit() }) @@ -321,17 +394,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const text = line.trim() if (!text) return - const agent = ctx.agents.get(agentId) - if (!agent) { - ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + if (failedStartup !== undefined) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`) return } - submittedWork = true - if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) + const agent = target + if (agent === undefined || !targetReady) { + // Initial exact-id restoration is asynchronous. Preserve input until + // session-start, the first supported point for queueing agent work. + // After a previously ready target disappears, a line in the HMR gap + // still fails loud unless its exact replacement is already publishing. + if (!hadReadyTarget || agent !== undefined) { + submittedWork = true + queuedInput.push(text) + return + } + ctx.logger.error('ui-stdio: main agent is not running') + return } + submit(agent, text) }) reader.on('close', () => { // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); @@ -347,31 +428,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt disposePendingQuestions() disposeUserInteractionProvider() disposeStatusListener() + disposeCreatedListener() + disposeSessionStartListener() + disposeDisposedListener() + disposeStartupFailedListener() reader.close() } }, 'ui-stdio') } /** - * Open the terminal channel once its configured agent exists. Generated stdio - * projects boot the Cordis tree first and create or resume the agent from - * developer code immediately afterward, so stdin must remain untouched until - * the matching `agent/created` notification arrives. + * Open the terminal channel for one exact identity. The chat registers before + * that agent necessarily exists so it can buffer startup input and observe a + * config-start failure instead of leaving piped stdin hanging. * @param ctx - the context supplying the agent registry and event stream. * @param config - presentation and target-agent configuration. * @param runtime - process-I/O seam. */ export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { - const agentId = AgentId(config.agent ?? 'main') - if (ctx.agents.get(agentId) !== undefined) { - createStdioChat(ctx, config, runtime) - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - createStdioChat(ctx, config, runtime) - }) + createStdioChat(ctx, config, runtime) } /** diff --git a/packages/ui/stdio/tests/readline.spec.ts b/packages/ui/stdio/tests/readline.spec.ts index 638e98bf59..6a97eab06a 100644 --- a/packages/ui/stdio/tests/readline.spec.ts +++ b/packages/ui/stdio/tests/readline.spec.ts @@ -16,9 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its label map from the registry at install; this suite only + // The UI seeds its root target from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { list: vi.fn(() => []) }, + agents: { roots: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, } as unknown as Context } diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index 7bb6a6f245..a3069462ff 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts' @@ -57,17 +57,23 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, - // A minimal session stub: the UI reads only `session.header.id` (to map the - // session back to its agent id for the turn-boundary label). - session: { header: { id: `${id}-session` } }, + // A minimal session stub with the agent's shared durable identity. + session: { id, header: { id } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } +/** Register a fake configured agent and cross the supported startup-work boundary. */ +function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { + const dispose = ctx.agents.register(agent) + ctx.emit('agent/session-start', agent, source) + return dispose +} + /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(agentId: string): Session { - return { header: { id: `${agentId}-session` } } as Session +function makeSession(id: string): Session { + return { id, header: { id } } as Session } /** An `assistant/chunk` session event carrying one raw stream chunk. */ @@ -75,7 +81,11 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', agent: 'main' } +const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } + +function unrenderableFailure(): unknown { + return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } } +} async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -94,7 +104,7 @@ function flushExit(): Promise { } describe('mountStdio readiness', () => { - it('leaves stdin untouched until the configured agent is created', async () => { + it('opens before the configured agent is created so startup input can queue', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -103,9 +113,9 @@ describe('mountStdio readiness', () => { mountStdio(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - expect(out.text()).toBe('') + expect(out.text()).toBe('hi there\n> ') ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('') + expect(out.text()).toBe('hi there\n> ') ctx.agents.register(makeAgent('main')) expect(out.text()).toBe('hi there\n> ') await fiber.dispose() @@ -125,7 +135,7 @@ describe('mountStdio readiness', () => { await fiber.dispose() }) - it('waits for main when no target agent is configured', async () => { + it('opens for the default main identity when no target is configured', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -134,8 +144,9 @@ describe('mountStdio readiness', () => { mountStdio(inner, { welcome: 'ready' }, runtime) }, { inject: ['agents', 'userInteraction'] })) + expect(out.text()).toBe('ready\n> ') ctx.agents.register(makeAgent('other')) - expect(out.text()).toBe('') + expect(out.text()).toBe('ready\n> ') ctx.agents.register(makeAgent('main')) expect(out.text()).toBe('ready\n> ') await fiber.dispose() @@ -148,12 +159,11 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe('hi there\n> ') }) - it('falls back to default welcome/agent when called with empty config', async () => { + it('falls back to the default welcome when called with empty config', async () => { // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default welcome/agent itself. + // Loader's schemastery validation), so it must default the welcome itself. const { out } = await setup({}) expect(out.text()).toBe('ready.\n> ') - // And it drives the default agent id 'main'. }) it('detects readline terminal mode from both stream TTY flags', async () => { @@ -205,9 +215,8 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created populates the session-id → agent-id label map. - ctx.emit('agent/created', agent) - const session = makeSession('main') + ctx.agents.register(agent) + const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) @@ -218,35 +227,59 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\n> ') }) - it('falls back to the session id as the label when no agent is mapped', async () => { + it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() - // No agent/created emitted, so the label map is empty — the header id shows. + // No target exists, so the event's durable identity is the label. ctx.emit('session/event', makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[orphan-session turn 1] ') + expect(out.text()).toContain('[orphan turn 1] ') }) - it('seeds labels for agents already registered before the UI installs', async () => { - // The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber) - // fired its `agent/created` before the UI's listener existed, so the live listener alone - // would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead - // of falling back to the raw session id. + it('uses an agent already registered before the UI installs as its target', async () => { + // The pre-created `main` agent (and any agent surviving an HMR reload of just + // this fiber) fired its `agent/created` before the UI's listener existed, so + // the live listener alone would miss it. Seeding from `ctx.agents.list()` at + // install time preserves the terminal's fixed `[main turn N]` label. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) const agent = makeAgent('main') + // Durable lineage does not imply runtime child ownership: the stdio app + // may explicitly resume a persisted fork as its one configured agent. + ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(agent) // registered BEFORE the UI plugin below const { runtime, out } = makeRuntime() await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', makeSession('main'), { + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') }) + it('buffers input for a lineage-bearing configured agent until its session starts', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) + input.feed('continue') + await new Promise(resolve => setImmediate(resolve)) + + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) + ctx.emit('agent/session-start', unrelated, 'startup') + const resumed = makeAgent('resumed') + ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' + ctx.agents.register(resumed) + await new Promise(resolve => setImmediate(resolve)) + expect(resumed.sent).toEqual([]) + + ctx.emit('agent/session-start', resumed, 'resume') + await new Promise(resolve => setImmediate(resolve)) + + expect(unrelated.sent).toEqual([]) + expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') @@ -257,17 +290,63 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) - it('drops the label mapping on agent/disposed', async () => { + it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/created', agent) - ctx.emit('agent/disposed', agent) - // After disposal the map no longer resolves the agent id — fall back to the - // session header id. - ctx.emit('session/event', makeSession('main'), { + const dispose = ctx.agents.register(agent) + dispose() + // After disposal the event belongs to a non-target session, so its durable + // identity is rendered directly. + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[main-session turn 1] ') + expect(out.text()).toContain('[main turn 1] ') + }) + + it('keeps the target when a different agent is disposed', async () => { + const { ctx, out } = await setup() + const target = makeAgent('main') + ctx.agents.register(target) + ctx.emit('agent/disposed', makeAgent('other')) + ctx.emit('session/event', target.session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 1] ') + }) + + it('retargets only the exact identity after loop HMR recreation', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) + const oldRoot = makeAgent('main-session-fixed') + const prefixCollision = makeAgent('main-session-unrelated') + const disposeOld = ctx.agents.register(oldRoot) + ctx.agents.register(prefixCollision) + disposeOld() + const replacement = makeAgent('main-session-fixed') + ctx.agents.register(replacement) + input.feed('after hmr') + await new Promise(resolve => setImmediate(resolve)) + expect(replacement.sent).toEqual([]) + ctx.emit('agent/session-start', replacement, 'resume') + await new Promise(resolve => setImmediate(resolve)) + + expect(prefixCollision.sent).toEqual([]) + expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) + }) + + it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { + const { ctx, input } = await setup() + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) + const configured = makeAgent('main') + const disposeConfigured = registerReady(ctx, configured) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + + disposeConfigured() + input.feed('must not leak') + await new Promise(resolve => setImmediate(resolve)) + + expect(unrelated.sent).toEqual([]) + expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running') }) it('renders tool/call and tool/result session events', async () => { @@ -683,7 +762,7 @@ describe('createStdioChat input', () => { it('sends a typed line to an idle agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('do a thing') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]]) @@ -693,7 +772,7 @@ describe('createStdioChat input', () => { it('steers a typed line into a running agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('steer me') await new Promise(r => setImmediate(r)) expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]]) @@ -709,22 +788,57 @@ describe('createStdioChat input', () => { expect(agent.sent).toEqual([]) }) - it('logs and drops a line when the target agent is not running', async () => { + it('buffers a line until the initial target session starts', async () => { const { ctx, input } = await setup() const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) input.feed('nobody home') await new Promise(r => setImmediate(r)) - expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main') + expect(spy).not.toHaveBeenCalled() + + const agent = makeAgent('main') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) - it('drives the agent named in config, not a hardcoded id', async () => { - const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' }) + it('drops later input after the configured startup fails', async () => { + const { ctx, input } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const failure = unrenderableFailure() + ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) + + input.feed('cannot run') + await new Promise(r => setImmediate(r)) + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + ) + }) + + it('ignores a stale config-start failure after the exact target is ready', async () => { + const { ctx, input } = await setup() + const agent = makeAgent('main') + registerReady(ctx, agent) + ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale')) + + input.feed('still live') + await new Promise(r => setImmediate(r)) + + expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]]) + }) + + it('drives the exact app-configured resumed session', async () => { + const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') - ctx.agents.register(agent) + registerReady(ctx, agent, 'resume') input.feed('hi') await new Promise(r => setImmediate(r)) expect(agent.sent).toHaveLength(1) }) + }) describe('createStdioChat EOF exit', () => { @@ -738,7 +852,7 @@ describe('createStdioChat EOF exit', () => { it('waits for the agent to settle idle after running before exiting', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -753,10 +867,50 @@ describe('createStdioChat EOF exit', () => { expect(exit).toHaveBeenCalledWith(0) }) + it('keeps piped EOF pending until buffered startup input runs', async () => { + const { ctx, input, exit } = await setup() + input.feed('work') + input.finish() + await flushExit() + expect(exit).not.toHaveBeenCalled() + + const agent = makeAgent('main', 'idle') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) + ctx.emit('agent/status', agent, 'running') + ;(agent as { status: AgentStatus }).status = 'idle' + ctx.emit('agent/status', agent, 'idle') + await flushExit() + expect(exit).toHaveBeenCalledWith(0) + }) + + it('drains buffered piped input and exits when configured startup fails', async () => { + const { ctx, input, exit } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + input.feed('work') + input.finish() + await new Promise(r => setImmediate(r)) + ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated')) + await flushExit() + expect(exit).not.toHaveBeenCalled() + + ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure()) + await flushExit() + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', + ) + expect(exit).toHaveBeenCalledWith(0) + }) + it('schedules the exit only once when idle fires repeatedly', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') // sawRunning = true @@ -774,7 +928,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit on an idle transition for a different agent', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -788,7 +942,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit while a turn is still running at EOF', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') @@ -837,7 +991,7 @@ describe('createStdioChat disposal (HMR safety)', () => { it('removes the agent/status listener on dispose', async () => { const { ctx, fiber, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) await fiber.dispose() diff --git a/packages/ui/stdio/tsconfig.json b/packages/ui/stdio/tsconfig.json index 00cb815a75..e0c578ed32 100644 --- a/packages/ui/stdio/tsconfig.json +++ b/packages/ui/stdio/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-loop" + }, { "path": "../../core/session" }, diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 880b56ecc5..96d5a43856 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -23,15 +23,31 @@ This is the consumer package for the user-interaction seam. It does not render U ### Tool schema -**What the model sees**: The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags. +#### What the model sees -**Token effect**: Fixed schema cost on every request where the tool is visible. +The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags. + +#### Token effect + +Fixed schema cost on every request where the tool is visible. + +#### KV Cache effect + +Prefix-stable while the definition and visibility are unchanged. Plugin lifecycle or scoped restrictions may invalidate reuse from this schema. ### Tool-call history and result -**What the model sees**: The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"","selected":["