diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2b562cc321..e1493d04f2 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. +- **Transcript changes:** editor-visible or model-visible changes update snapshots or explain why no snapshot applies. Review expected-output 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-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 59dde3df0d..623cdf18e2 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -24,7 +24,7 @@ A strong simplification removes, folds, or demotes something real and has clear - A seam has methods every implementation must support but no consumer uses. - A package boundary exists only for test/demo/support code and adds publish or dependency overhead. - A feature implements speculative product generality: multi-session/session-load, background task rosters, live registry invalidation, mid-turn steering, tool-owned UI rendering, and similar shapes with no product owner. -- An invariant, rollback path, goldens set, or special-case test exists only to protect an unused surface. +- An invariant, rollback path, set of expected outputs, or special-case test exists only to protect an unused surface. - The simplified behavior may differ slightly, but the new behavior is still reasonable and easier to explain. Thin candidates are usually not enough for an RFC: deleting one typo, running `knip` once, removing an intentionally documented backend/adapter, or flagging "this looks complex" without call-site proof. @@ -37,7 +37,7 @@ Use parallel subagents when the user asks for breadth or many candidates. Give e - ACP and UI surfaces: `session/*` methods, terminal `_meta`, transcript rendering, single vs multi-session state. - LLM/tools/system prompt: stream/generate surfaces, assemblers, registries, tool schema defaults, presentation hooks. - Bash and tool execution: foreground/background split, task ownership, output spill files, executor methods. -- Packages/examples/scripts/tests: package boundaries, static inventories, redundant snapshot goldens, support packages. +- Packages/examples/scripts/tests: package boundaries, static inventories, redundant snapshot expected outputs, support packages. If subagents are unavailable, simulate the same breadth yourself. Do not let the first good candidate stop the survey. @@ -54,7 +54,7 @@ For complex asynchronous code, draw the ownership graph and map each sentinel, r For every symbol or behavior, classify consumers before writing: - Production corpus: `packages/*/src`, `examples/*/src`, `examples/**/*.yml`, runtime scripts, and loader/config paths. -- Non-production corpus: tests, README/docs, RFCs, snapshots, generated goldens, and comments. +- Non-production corpus: tests, README/docs, RFCs, snapshots, generated expected outputs, and comments. - Ambiguous corpus: examples and scripts that may be product smoke paths. Inspect usage before classifying. Use `rg` first. Good searches include the exact symbol, event name, package name, config key, method name with both `.name(` and `name(`, and any wire strings. Then read the call sites. `knip` can help, but it is not a substitute for understanding public interfaces, dynamic event names, tests, docs, and Cordis loader paths. diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml new file mode 100644 index 0000000000..328da95529 --- /dev/null +++ b/.github/workflows/expected-filenames.yml @@ -0,0 +1,21 @@ +name: Expected filenames + +on: + pull_request: + paths: + - '*[gG][oO][lL][dD][eE][nN]*' + - '**/*[gG][oO][lL][dD][eE][nN]*' + - '!vendor/**' + +permissions: + contents: read + +jobs: + expected-filenames: + name: no golden filenames + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check tracked filenames + run: scripts/check-expected-filenames.sh diff --git a/AGENTS.md b/AGENTS.md index efaa29bbac..cd3505ea8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,8 @@ 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/headless/TUI replay vs goldens; filter: -t -pnpm run test:snapshot:record # re-record goldens (needs key) +pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t +pnpm run test:snapshot:record # re-record expected outputs (needs key) pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection @@ -110,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. @@ -120,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. @@ -132,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/docs/AGENTS.md b/docs/AGENTS.md index f7635c659b..a0c30c5545 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 c753f59992..888b24043a 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -32,6 +32,11 @@ 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->>Tools: classify pending call by executionMode @@ -46,9 +51,12 @@ sequenceDiagram 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 @@ -56,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 2644e787b6..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,7 +16,7 @@ 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 agents, creation delegation, `agent/*` events, and process-local initiating Agent scope | +| `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 @@ -80,33 +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) - 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 - '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 `additionalContexts`—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 becomes queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering while preserving queued prompts. +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. @@ -116,11 +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). - -### Initiating Agent Scope - -`AgentLoop` runs each process-local driver inside `ctx.agents.withInitiator()`; the [decision](rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns boundary and explicit-identity rules. +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 @@ -150,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 that 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; and `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 @@ -164,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 c119b79c18..58641456b1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -224,7 +224,7 @@ 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.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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c9fbb6c0b2..0aa4038c70 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -301,7 +301,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 } ``` @@ -998,7 +1000,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` 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 9a2aec30a5..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: 75cea09ad9dd1db36acdce7998cd7f04e1b97076 -extension-cookbook.zh.md: 6db3947b5414fb81f963aae79f908292df2f3440 +extension-cookbook.md: c6bf6ddd4bf0da8bd7377ab57e779750b72bd25e +extension-cookbook.zh.md: dcc188b7d3ac44f5d86253c20147e95da0bea648 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 75cea09ad9..c6bf6ddd4b 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -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 6db3947b54..dcc188b7d3 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -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 f522790fd0..7d7472a33e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:147`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,33 +75,51 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../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:311`](../../packages/core/agent/src/types.ts) -### `agent/pre-step` — serial +### `agent/post-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 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 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 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 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 +/** + * 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 */ -'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 ``` -Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) +Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) @@ -145,7 +163,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already 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:169`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -170,9 +188,34 @@ Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data- 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 /** @@ -180,9 +223,9 @@ Compose request-only messages placed before derived history. The frozen result i * 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. @@ -216,7 +259,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to 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:182`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -236,7 +279,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no 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:159`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:165`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -279,7 +322,7 @@ Override whether the turn continues. The default continues after tool calls or s 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:262`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -300,7 +343,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a 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:272`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` @@ -430,7 +473,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 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/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 267c2f0caa..10cd0bbf05 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -346,21 +346,18 @@ Abstract compaction service. Implementations own trigger policy, retention, and ```ts cordis-catalog /** - * 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[], signal: AbortSignal, ): Promise +abstract compactIfNeeded( agent: CompactAgentContext, trigger: CompactionTrigger, signal: AbortSignal, ): Promise /** * Forcibly compact a range of surface nodes into a single summary node. @@ -382,9 +379,9 @@ abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Types: [CompactionResult](../core-data-structures/compaction.md) · [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) @@ -515,8 +512,11 @@ 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. 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. */ @@ -525,7 +525,7 @@ stream(options: GenerateOptions): AsyncIterable 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:94`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index ccc212a895..f6cdfd5b8b 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -51,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 06781b60a5..1d45f3814a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -445,6 +445,22 @@ type ContinuationDecision = | { 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 diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index ce8c1bf074..3526687f3e 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -37,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. @@ -67,7 +68,7 @@ interface AppIdentity { ## `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 /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 78f878106c..4f6e6daff0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,24 +8,26 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `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:141`](../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:150`](../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:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:204`](../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/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:169`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `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:182`](../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:159`](../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/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:262`](../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:272`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `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) | +| `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) | diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 120121e452..f11a1ff19e 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -106,6 +106,7 @@ | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | | executor | 执行器 | | | | +| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | diff --git a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md index 191c13459c..ccdb725bfb 100644 --- a/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md +++ b/docs/postmortem/0002-js-expression-disabled-filesystem-tools.md @@ -4,7 +4,7 @@ Status: resolved ## Executive summary -The ACP example attempted to enable filesystem plugins conditionally with `disabled: !!js ...`, but Cordis evaluates JavaScript expressions only inside plugin `config`. The raw expression object was truthy, so the filesystem stack was always disabled. Snapshot refresh then accepted `UNKNOWN_TOOL` results as new goldens. The fix uses an explicit filesystem overlay and adds static-config and snapshot-result guards. +The ACP example attempted to enable filesystem plugins conditionally with `disabled: !!js ...`, but Cordis evaluates JavaScript expressions only inside plugin `config`. The raw expression object was truthy, so the filesystem stack was always disabled. Snapshot refresh then accepted `UNKNOWN_TOOL` results as new expected outputs. The fix uses an explicit filesystem overlay and adds static-config and snapshot-result guards. ## Summary @@ -22,7 +22,7 @@ The live confined default did not gain unintended filesystem access. A naive int - PR #261 consolidated ACP compositions and refreshed the filesystem snapshots while introducing conditional filesystem entries. - All unit, coverage, snapshot, documentation, build, and hygiene checks passed. -- Review of the refreshed filesystem goldens found generic failed cards and structured `UNKNOWN_TOOL` results. +- Review of the refreshed filesystem expected outputs found generic failed cards and structured `UNKNOWN_TOOL` results. - A real Loader boot confirmed that every `disabled` value remained an expression object and every filesystem fiber was absent. ## Root cause @@ -36,10 +36,10 @@ The snapshot framework treated any deterministic transcript as valid behavior. H - Filesystem scenarios boot `fs.cordis.yml`, an explicit fixed full-access overlay with a paired replay config and its own request-header class. - [`AGENTS.md`](../../AGENTS.md) and the [Cordis primer](../cordis-primer.md#loader-configuration) state that `!!js` is valid only under plugin `config` and conditional composition uses overlays. - `verify-cordis-config` parses repository Cordis YAML and rejects expression nodes in Loader entry metadata, including include patches and inserted entries. -- `dsh-acp-snapshot` rejects structured `UNKNOWN_TOOL` results in fresh runs and committed session fixtures before they can become accepted goldens. +- `dsh-acp-snapshot` rejects structured `UNKNOWN_TOOL` results in fresh runs and committed session fixtures before they can be committed as expected outputs. ## Lessons - A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries. -- A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the golden. +- A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the expected output. - Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely. diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 16a5563532..d3afd838c8 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -160,6 +160,7 @@ 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 | @@ -206,11 +207,11 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Property-based testing for protocol-shaped code](implemented/testing/2026-06-11-property-based-testing.md) | 2026-06-11 | | [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | -| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md) | 2026-06-20 | | [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | | [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | | [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | -| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | +| [Hook snapshot matrix — end-to-end expected outputs for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | | [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 | 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-07-02-tool-render-intent-union.md b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md index 37507c0c55..899d30416a 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -10,7 +10,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot-golden replay path). +The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot replay path). ## Decision 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 c154b2080b..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 @@ -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. 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..cbbfef6611 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. @@ -50,5 +50,5 @@ Like MiniCode, the conversation advances append-only and resets only when model- - The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam. - Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. - Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. -- Snapshot goldens changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. +- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. - FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. 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-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 0655e89ba5..98131dce38 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md: b177af24e988c6a314db522b8de0d1c09e30464f -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0b964e8a748e4adcc32c017957e5294a3f258365 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 80d5ab64682f1b72ca1dfa1f96bc34f2a3db5f2d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: aea1fb66136b31e0a75ba51471fec4dadd960e8f diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index b177af24e9..80d5ab6468 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -21,7 +21,7 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `--sea` requires target ≥ node22; the exe uniformly targets node24. One pkg invocation packages exactly one target; multi-platform builds invoke it once per platform. -Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay goldens, `$DSH_SNAPSHOT`); this document says "VFS" for the former. +Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. ### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 0b964e8a74..aea1fb6613 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -21,7 +21,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。 -术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放 golden、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 +术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放预期输出、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 ### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两包 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 index 9e2a50bd57..52128ea344 100644 --- 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 @@ -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-agent-initiator-scope.md: b3c9be0be1dea29568dfcdeb0578e643734486e8 -2026-07-15-agent-initiator-scope.zh.md: 55494977b8ade0d380fa21b25171bce65a46a9fb +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 index b3c9be0be1..a9df15beb8 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -16,7 +16,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in `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, ...)`. Concurrent drivers therefore 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. +`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. @@ -30,9 +32,9 @@ This decision extends the [Agent registration-scope contract](2026-07-08-agent-s ## 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, and root teardown. 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. +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. -Only a test-double host-aware transport consumes ambient identity; it 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. +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 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 index 55494977b8..4198f066ef 100644 --- 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 @@ -16,7 +16,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 `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` 生命周期。因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 +`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 只在进程内有效。 @@ -30,9 +32,9 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 验证 -Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 +Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 -只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 +测试替身形式的宿主感知传输层在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 ## 考虑过的替代方案 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-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index f32355326f..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,7 +17,7 @@ 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 @@ -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. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The pre-step integration resolves a provisional provider/model pair from the latest logged request header, then `AgentOptions`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its target from explicit config, the latest logged routed target, 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,14 +104,14 @@ 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. @@ -119,7 +119,7 @@ Two failure paths, both documented: ## 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-29-todo-write-tool.md b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md index 06bc6daf6a..127da5d09f 100644 --- a/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md +++ b/docs/rfc/implemented/feature/2026-06-29-todo-write-tool.md @@ -49,7 +49,7 @@ Four tiers, designed up front: - **Real-Loader path** — the plugin run through `Loader.unwrapExports`, asserting the namespace export shape survives (it HAS `inject`, so a stray default would crash at load — postmortem/0001). - **Full-loop integration** — a scripted mock model calls `todo_write` through the real agent loop; the `todo/write` event lands and a second call replaces it. - **`session/load` replay** — a persisted `todo/write` re-emits the `plan` update when a fresh ACP bridge loads the session. -- **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot golden gains the `plan` notification and the log event. +- **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot expected output gains the `plan` notification and the log event. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md index 1be5b225fe..27c108f0ce 100644 --- a/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -199,7 +199,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package. - **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal. -- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded golden) and make every replay depend on spawning an external MCP server process — for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then. +- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded expected output) and make every replay depend on spawning an external MCP server process — for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then. ## Consequences 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-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 35f65c38f2..cd4f3b078b 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 @@ -144,7 +144,7 @@ If the complete logical result fits under the inline cap, no formatted spill art - The first-party tool-owned spill precedent is covered directly: spill backend present, spill backend absent, `saveText()` failure, and missing spill owner. - The package has real Loader-path coverage for the namespace plugin export shape (`name`, `inject`, `Config`, and `apply`, with no default export). - A real-executor integration suite (`dsh-bash-local` + a real `rg`) verifies the world: hostile patterns stay inert, per-session cwd resolution, VCS-metadata exclusion, modification-time ordering, and real ripgrep stderr classification. It self-skips where `rg` is not on PATH (a CI accommodation mirroring the keyless e2e skip); the fake-executor suite alone carries the per-file 100% coverage gate. -- Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every golden would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session. +- Snapshot gap note for the transcript-visible spill notice: this landed with the gap note, not a snapshot. The snapshot tier replays the acp-agent tree, and adding the search plugin there changes the assembled system prompt — every expected output would need re-recording with a real key, which the implementing environment did not hold. The spill notice's exact transcript text is pinned by unit tests (`formatGlobOutput`/`formatGrepOutput` and the through-the-registry spill tests); wiring the plugin into the acp-agent tree plus a `test:snapshot:record` pass is the follow-up for the next key-holding session. ## Consequences 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-drop-unconsumed-llm-adapter-change-event.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md index ab921dd62c..9ce20ff7d4 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md @@ -24,7 +24,7 @@ If an LLM adapter browser or dynamic model-picker needs this signal later, reint ## Verification -`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot goldens and the echo-agent smoke are byte-unchanged. +`llm/adapter-change` and its emits are gone and the regenerated cordis catalog is fresh; HMR-safety holds (disposing a contributing fiber removes the adapter); `tools/change` and `system-prompt/change` remain documented and tested; and no production path changed observable behavior — the ACP snapshot expected outputs and the echo-agent smoke are byte-unchanged. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md index 2b10bf36db..3a5dfcda26 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md +++ b/docs/rfc/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md @@ -26,7 +26,7 @@ This is the [drop-mutable-session-summary](../../implemented/simplification/2026 ## Verification -`streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone required are gone with no new dead exports; both real adapters are exercised through `stream()` and the shared assembler; the loop behaves identically (ACP snapshot goldens unchanged); and the README, architecture doc, and module docs carry no mention of the removed surfaces. +`streamBlocks`, `generate`, `llm/generate`, and the assembler helpers they alone required are gone with no new dead exports; both real adapters are exercised through `stream()` and the shared assembler; the loop behaves identically (ACP snapshot expected outputs unchanged); and the README, architecture doc, and module docs carry no mention of the removed surfaces. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md index 0fc11a1c17..0de8f5732c 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -23,7 +23,7 @@ A future permission/containment layer might want the pre-resolution path for err ## Verification -The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditRequest`) and the version fields on the other outcome types are untouched; the test fakes shrank with the types. `formatEditOutput`'s emitted text is unchanged for both `replace_all` branches, so no snapshot golden churned. +The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditRequest`) and the version fields on the other outcome types are untouched; the test fakes shrank with the types. `formatEditOutput`'s emitted text is unchanged for both `replace_all` branches, so no snapshot expected output churned. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md index a0a383b255..6591aab8f3 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -8,7 +8,7 @@ Status: implemented `agent/steering` duplicated the immediately preceding durable `steering/message` with the same payload. `agent/queued` remains the live-only signal because it fires before persistence and covers work that may be cancelled before entering the log. -Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observed the mirror. +Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix expected outputs pin — and every one of those consumers observes the durable event. Nothing observed the mirror. ## Decision diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index 72172144d0..7cbf67043f 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -27,4 +27,4 @@ Unsupported vocabulary can return when a real consumer exists. `durationMs` rema ## Consequences -The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. +The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the expected outputs. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index 191ddd833f..39edd8fee9 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -6,7 +6,7 @@ Status: implemented Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: -1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/examples/acp-demo/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model }` (`packages/examples/acp-demo/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot expected output — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. 2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". ## Decision 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 238f2044ad..2f7c0dbd8e 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 @@ -12,11 +12,11 @@ This RFC records the decision to add a third test tier — **snapshot tests** ## Decision -A snapshot test boots the real ACP example, drives its stdio protocol from a deterministic script, and compares normalized output with committed goldens. A session log recorded once from the real API supplies all later model streams. The fixture is the product's ordinary persisted JSONL. +A snapshot test boots the real ACP example, drives its stdio protocol from a deterministic script, and compares normalized output with committed expected outputs. A session log recorded once from the real API supplies all later model streams. The fixture is the product's ordinary persisted JSONL. ### The fixture is the persisted session JSONL -Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral golden. +Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral expected output. ### Replay derives the model script from the log @@ -48,12 +48,12 @@ Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct: -1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). Compared against a committed `stdout.golden.jsonl`. +1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). Compared against a committed `stdout.expected.jsonl`. 2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning RFC](2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. The surfaces are complementary: stdout covers bridge projection, while JSONL covers loop, tool, and boundary structure that the projection omits. -Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout golden remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout golden; normalized session equality never overwrites the replay fixture. +Normalization replaces session, cwd, protocol-id, timestamp, path, and process volatility while preserving deterministic sequence numbers. Scenarios constrain real bash use to stable commands. The stdout expected output remains wire-shaped JSONL and every raw line must parse as JSON. Vitest updates only the stdout expected output; normalized session equality never overwrites the replay fixture. ### Isolation: normalization now, sandbox later @@ -65,11 +65,11 @@ Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-log ### Two subcommands, replay in the default gate -`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout golden. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.golden.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters. +`pnpm run test:snapshot` replays committed fixtures keylessly; `test:snapshot:record` uses the real API and rewrites the harvested session log and stdout expected output. Missing fixtures fail loud. Every scenario carries `input.json`, `stdout.expected.jsonl`, and `session.jsonl`; no-model cases use a header-only log. `replay.override.json` is required only for scenarios marked `overridden`, because its presence replaces derived replay. Fixture guards reject missing, mismatched, and orphaned files. Both commands accept scenario filters. ## Alternatives considered -- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral golden. +- **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output. - **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. - **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md new file mode 100644 index 0000000000..ef0d3bd4ab --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md @@ -0,0 +1,35 @@ +# RFC: Use `session.jsonl` as the only snapshot session-log artifact + +Status: implemented + +## Problem + +Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.expected.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.expected.jsonl`. In the current fixtures, the two normalized logs are identical for 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.expected.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 + +The `session.expected.jsonl` concept is removed entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`: + +- For recorded scenarios, `session.jsonl` remains the raw harvested log. Replay still derives model chunks from it, and the snapshot test compares the replay run's normalized persisted log against normalized `session.jsonl`. +- For authored override scenarios, `replay.override.json` drives model behavior and `session.jsonl` holds the expected produced session log. The replay adapter ignores the fixture for model chunks when the override exists, so the same file can be the expected log without affecting replay behavior. +- For no-model scenarios, `session.jsonl` can stay as the minimal fixture needed to boot `llm-replay`; no session-log comparison is needed unless the scenario creates a persisted session. + +Stdout expected outputs remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. + +## Alternatives considered + +**Normalizing both sides against a shared (replay-run) context** — rejected: `normalizeSessionLog` scrubs cwd by exact string match, so the fixture's recorded cwd would survive unscrubbed and every compare would fail. Each side normalizes against its own header-derived context — the implementation note below carries the mechanics. + +## Verification + +`session.expected.jsonl` appears nowhere in the snapshot harness, fixtures, orphan guards, or docs; the snapshot test derives the expected session log from `session.jsonl` for every model scenario; authored sidecar scenarios commit their expected produced log as `session.jsonl` with `replay.override.json` as the model-behavior override; and the orphan-fixture guards know which files each scenario kind requires. The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) describes the reduced fixture set. + +## Consequences + +Reviewers lose one artifact name that made the expected persisted log visually separate from the replay fixture. The stdout expected output still protects the editor transcript, and comparing replay output to `session.jsonl` preserves the loop/persistence regression check without duplicating files. + +## Implementation note + +Each side is normalized against its own header values because recording and replay have different ids, paths, and timestamps. `fixtureContext()` derives the fixture context from its header, making already-normalized fixtures idempotent. Session logs use plain equality rather than file-snapshot updates, so comparison never rewrites fixtures. 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 deleted file mode 100644 index 5c9e1014b8..0000000000 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ /dev/null @@ -1,35 +0,0 @@ -# RFC: Use `session.jsonl` as the only snapshot session-log artifact - -Status: implemented - -## Problem - -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 }`, 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 - -The `session.golden.jsonl` concept is removed entirely. Every scenario has at most one committed session-log artifact, `session.jsonl`: - -- For recorded scenarios, `session.jsonl` remains the raw harvested log. Replay still derives model chunks from it, and the snapshot test compares the replay run's normalized persisted log against normalized `session.jsonl`. -- For authored override scenarios, `replay.override.json` drives model behavior and `session.jsonl` holds the expected produced session log. The replay adapter ignores the fixture for model chunks when the override exists, so the same file can be the expected log without affecting replay behavior. -- For no-model scenarios, `session.jsonl` can stay as the minimal fixture needed to boot `llm-replay`; no session-log comparison is needed unless the scenario creates a persisted session. - -Stdout goldens remain unchanged; they are the editor-facing projection and are not redundant with the session fixture. - -## Alternatives considered - -**Normalizing both sides against a shared (replay-run) context** — rejected: `normalizeSessionLog` scrubs cwd by exact string match, so the fixture's recorded cwd would survive unscrubbed and every compare would fail. Each side normalizes against its own header-derived context — the implementation note below carries the mechanics. - -## Verification - -`session.golden.jsonl` appears nowhere in the snapshot harness, fixtures, orphan guards, or docs; the snapshot test derives the expected session log from `session.jsonl` for every model scenario; authored sidecar scenarios commit their expected produced log as `session.jsonl` with `replay.override.json` as the model-behavior override; and the orphan-fixture guards know which files each scenario kind requires. The [ACP snapshot tests RFC](../../implemented/testing/2026-06-19-acp-snapshot-tests.md) describes the reduced fixture set. - -## Consequences - -Reviewers lose one artifact name that made the expected persisted log visually separate from the replay fixture. The stdout golden still protects the editor transcript, and comparing replay output to `session.jsonl` preserves the loop/persistence regression check without duplicating files. - -## Implementation note - -Each side is normalized against its own header values because recording and replay have different ids, paths, and timestamps. `fixtureContext()` derives the fixture context from its header, making already-normalized fixtures idempotent. Session logs use plain equality rather than file-snapshot updates, so comparison never rewrites fixtures. diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index 8135f6afd1..811930af00 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subprocess, replays a recorded session through [`dsh-llm-replay`](../../../../packages/support/llm-replay), and diffs the normalized stdout transcript + re-persisted session log against committed goldens. It is the only tier that exercises the full editor-facing transcript end to end. +The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subprocess, replays a recorded session through [`dsh-llm-replay`](../../../../packages/support/llm-replay), and diffs the normalized stdout transcript + re-persisted session log against committed expected outputs. It is the only tier that exercises the full editor-facing transcript end to end. It was built for ONE session per process, and that assumption is wired into two places: 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 fd87377fb9..0ba04c4abb 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 @@ -1,10 +1,10 @@ -# RFC: Hook snapshot matrix — end-to-end goldens for both bridges +# RFC: Hook snapshot matrix — end-to-end expected outputs for both bridges Status: implemented ## Problem -The hook bridges — [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) (7 Claude Code hook points) and [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex) (5 Codex points) — map external hook commands onto the harness interception seams. They carry deep unit and coverage-spec coverage (every decision arm, every payload dialect, driven against a mocked seam) plus one key-gated e2e (`hooks.e2e.ts`, a live `PreToolUse` block). But the full-transcript snapshot tier — the one net that boots the real `acp-agent` subprocess, replays a recorded session keyless, and diffs the normalized ACP stdout + re-persisted log against committed goldens — covered exactly ONE hook: a Claude `UserPromptSubmit` block (`hook-cc-promptsubmit-block`). +The hook bridges — [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude) (7 Claude Code hook points) and [`dsh-hooks-codex`](../../../../packages/hooks/hooks-codex) (5 Codex points) — map external hook commands onto the harness interception seams. They carry deep unit and coverage-spec coverage (every decision arm, every payload dialect, driven against a mocked seam) plus one key-gated e2e (`hooks.e2e.ts`, a live `PreToolUse` block). But the full-transcript snapshot tier — the one net that boots the real `acp-agent` subprocess, replays a recorded session keyless, and diffs the normalized ACP stdout + re-persisted log against committed expected outputs — covered exactly ONE hook: a Claude `UserPromptSubmit` block (`hook-cc-promptsubmit-block`). That is the tier a mocked unit test structurally cannot be: it exercises the REAL bridge translating a REAL hook process's outcome into the REAL seam decision, then the REAL loop's reaction, rendered exactly as an editor sees it. A bridge-translation or loop-structure regression that left every unit green would still escape it for every hook point but one — and for the Codex bridge, the ACP example did not even LOAD it, so no Codex hook could fire end-to-end at all. @@ -29,22 +29,22 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook- diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md index 70730f0382..03c25572d4 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -10,7 +10,7 @@ Status: implemented `cordis.snapshot.yml` includes the live config, disables the named DeepSeek adapter by id and name, and inserts the replay adapter. Every other entry therefore comes from the shipping tree. Replay selects the overlay; recording still boots `cordis.yml`, and the load guard permits the intentionally disabled entry. -One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included. +One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical expected outputs included. ## Alternatives considered diff --git a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md index 69af84b0f0..68f7fce66a 100644 --- a/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md +++ b/docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md @@ -8,7 +8,7 @@ An ACP snapshot suite needs to prove the exact composed system prompt and tool-s ## Decision -Exactly one scenario per header-composition class is flagged `pinsHeader`. Its directory splits the pin by review format: `system-prompt.golden.md` contains the normalized full prompt sequence as ordinary Markdown, `tool-schemas.golden.json` contains the corresponding complete schema sequence as structured JSON, and `session.jsonl` retains config, reason, and any model-visible prefix while storing `header.system` and `header.tools` as `"{{system}}"` / `"{{tools}}"`. Every other JSONL uses the same prompt and tool tokens and also tokenizes session-prefix content. The pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per class. +Exactly one scenario per header-composition class is flagged `pinsHeader`. Its directory splits the pin by review format: `system-prompt.expected.md` contains the normalized full prompt sequence as ordinary Markdown, `tool-schemas.expected.json` contains the corresponding complete schema sequence as structured JSON, and `session.jsonl` retains config, reason, and any model-visible prefix while storing `header.system` and `header.tools` as `"{{system}}"` / `"{{tools}}"`. Every other JSONL uses the same prompt and tool tokens and also tokenizes session-prefix content. The pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per class. The pure `scrubSystemPrompts` and `scrubToolSchemas` normalizers independently tokenize every stored full header. `scrubRequestHeaders` also tokenizes session-prefix content for non-pinning scenarios while retaining header count, field presence, config, reason, and prefix message count. Record and refresh write-back apply the appropriate scrub before writing JSONL and regenerate both sidecars from the normalized live full-header sequence, so neither path can reintroduce prompt/schema bulk into JSONL or leave a review artifact stale. diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 85d89d5f14..ecdd40f313 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). +The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure expected-output normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout expected-output and log comparisons, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was also triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness. Location decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. @@ -18,7 +18,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. -**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session..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. +**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session..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.expected.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 @@ -26,7 +26,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su - **A shared module directory under `examples/`** — keeps the code outside the coverage gate and forces relative imports across example boundaries, against the package-name import convention; `examples/` leaves stay thin by design. - **A `/testing` subpath export of `dsh-acp-demo`** — couples test infrastructure into a product package's surface and dependency set; `packages/support/` exists precisely for real-but-lower-compatibility dev/test packages, with `dsh-llm-replay` as the precedent this package completes. - **Export raw test-body functions instead of a suite factory** — each example would re-own the `describe`/`it` skeleton (~80 lines of registration boilerplate per suite) for no flexibility gain; the factory keeps consumers to a scenario table plus one call, and the exported pure helpers preserve unit-testability inside the factory design. -- **An injectable ACP `Client` factory instead of declarative `permissionAnswers`** — maximally flexible, but it leaks SDK client construction to every consumer and reopens per-example drift in exactly the layer being unified; a declarative queue keeps `input.json` the single scripting surface and stays golden-normalizable. +- **An injectable ACP `Client` factory instead of declarative `permissionAnswers`** — maximally flexible, but it leaks SDK client construction to every consumer and reopens per-example drift in exactly the layer being unified; a declarative queue keeps `input.json` the single scripting surface and compatible with expected-output normalization. - **Generalize beyond ACP (a transport-agnostic snapshot harness)** — no second transport exists; the harness is ACP-shaped end to end (SDK client, JSON-RPC frames, `session/update` waiters), and a speculative abstraction would be a seam split ahead of any consumer. ## Testing 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 index f111c337dc..6f3df04716 100644 --- 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 @@ -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-18-tui-terminal-state-snapshots.md: a1363a521372c11dab8b239cad87df5ff5ca8f22 -2026-07-18-tui-terminal-state-snapshots.zh.md: aa365ba1f2ba17e5bc5409dbdc3736cbc29fe26a +2026-07-18-tui-terminal-state-snapshots.md: a609e7c167ddf7ebe594f6793e34a48c555c10a9 +2026-07-18-tui-terminal-state-snapshots.zh.md: 8690a6f83a827619682b82c2df56d360e104c53b 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 index a1363a5213..a609e7c167 100644 --- 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 @@ -25,19 +25,19 @@ The runnable TUI has its own `examples/tui-agent` leaf beside the readline `repl ### 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. +Each example-level scenario directory owns `session.jsonl`, optional child logs `session..jsonl`, and `terminal.expected.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 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 expected output 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. +The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their primary and child JSONL logs and terminal expected outputs. 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 expected outputs. 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. +Each expected output 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. +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 expected output files. ### Required scenario matrix @@ -58,7 +58,7 @@ Every checkpoint enforces theme independence across the complete terminal state: - **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. +- **Reuse ACP stdout expected outputs as the TUI oracle** — rejected because a recorded model journey is transport-neutral but its presentation is not. TUI scenarios own terminal expected outputs 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. @@ -67,4 +67,4 @@ Every checkpoint enforces theme independence across the complete terminal state: - 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. +- Expected outputs 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 index aa365ba1f2..8690a6f83a 100644 --- 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 @@ -25,19 +25,19 @@ TUI 覆盖分为四个互补层次: ### 已录制会话回放 -每个示例级场景目录都包含 `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 都使用生产实现。 +每个示例级场景目录都包含 `session.jsonl`、可选的子会话日志 `session..jsonl`,以及 `terminal.expected.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本,并将子日志绑定到新建的子会话;这是测试中唯一的 mock 边界。agent loop、bash 与文件系统实现、Code Mode worker、subagent 提供方、工作流 worker、Cordis 工具、呈现器和 TUI 都使用生产实现。 -如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,测试都会失败。即使终端金标表面正确,这些断言也能阻止失败或被绕过的生产路径混入结果。 +如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,测试都会失败。即使终端预期输出表面正确,这些断言也能阻止失败或被绕过的生产路径混入结果。 -真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新;录制模式会重写其主会话与子会话 JSONL 日志以及终端金标。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本,因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本,并且只重写派生的终端金标。普通回放只比较而不写入,未知模式值会快速失败。 +真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新;录制模式会重写其主会话与子会话 JSONL 日志以及终端预期输出。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本,因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本,并且只重写派生的终端预期输出。普通回放只比较而不写入,未知模式值会快速失败。 ### 语义终端投影 包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 -每份金标把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 +每份预期输出把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 -每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。两套测试都拥有封闭清单,会拒绝缺失的场景、缺失的检查点和遗留金标文件。 +每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。两套测试都拥有封闭清单,会拒绝缺失的场景、缺失的检查点和遗留预期输出文件。 ### 必需场景矩阵 @@ -58,7 +58,7 @@ TUI 覆盖分为四个互补层次: - **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。 - **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。 - **通过追加会话事件构造所有完整流程**:不予采纳,因为人工编写的事件序列可能与 agent loop、工具执行、子会话绑定或 worker 行为发生偏差,但呈现测试仍然保持绿色。直接构造事件只用于渲染器瞬态。 -- **复用 ACP stdout 金标作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇,但拥有独立的终端金标。 +- **复用 ACP stdout 预期输出作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇,但拥有独立的终端预期输出。 - **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。 - **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。 @@ -67,4 +67,4 @@ TUI 覆盖分为四个互补层次: - 当真实 Code Mode、工作流、subagent、文件系统、bash 或 Cordis 路径损坏时,已完成高级快照会失败,不会继续接受伪造的结果事件。 - TUI 视觉回归会产生便于阅读的单元格和样式 diff,而 JSONL fixture 会保留触发生产路径的确切模型分片。 - 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影;终端特有行为仍需由 PTY 冒烟测试覆盖。 -- 金标有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新;模型流程变更使用录制模式,并同时评审 JSONL 与终端 diff。 +- 预期输出有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新;模型流程变更使用录制模式,并同时评审 JSONL 与终端 diff。 diff --git a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md index 94313fd0ad..2aad598f6d 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md +++ b/docs/rfc/rejected/simplification/2026-06-20-drop-durable-step-boundaries.md @@ -4,7 +4,7 @@ Status: rejected — `step/end` is the durable indication that a model step fini ## Problem -The session log stores `step/start` and `step/end` events even though every step-scoped event already carries `{ turn, step }`: assistant chunks, assistant messages, tool calls, tool results, usage, and errors. `deriveMessages()` ignores step boundaries, ACP ignores them for UI, and the main consumers are invariants, tests, snapshot goldens, and crash repair. +The session log stores `step/start` and `step/end` events even though every step-scoped event already carries `{ turn, step }`: assistant chunks, assistant messages, tool calls, tool results, usage, and errors. `deriveMessages()` ignores step boundaries, ACP ignores them for UI, and the main consumers are invariants, tests, snapshot expected outputs, and crash repair. The rejected argument was that boundary events make the log more ceremonial than informative. In practice, `step/end` is concrete information: a reader can tell whether a model request finished, crashed, or is being repaired without deriving that state from the next event. A bare `step/start` is likewise useful for a model request that began but produced no chunks before failing. diff --git a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md index 6125feaa8b..87d739ba54 100644 --- a/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/docs/rfc/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -22,7 +22,7 @@ As a smaller alternative, replace the current optional-field bag with one explic - `ToolCallPresentation`, `ToolResultPresentation`, `ToolTerminal`, and `ToolCallKind` disappear unless a minimal generic UI type still needs one. - ACP no longer keeps presenter pending state or calls tool callbacks during live streaming/load replay. - `dsh-tool-bash` no longer parses rendered text to recover exit status for a UI pill. -- Snapshot goldens show generic tool cards and text results. +- Snapshot expected outputs show generic tool cards and text results. ## What we give up diff --git a/docs/testing.md b/docs/testing.md index cd3b1aa9e8..1a2d6cb192 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`): 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)). +- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs 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 expected outputs; 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 expected-output 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 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3dacb56902..dfb97deef3 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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\"." } } } diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 124117afef..37b420378c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -5,10 +5,10 @@ import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from /** * The acp-agent example's snapshot suite: the scenario table for * `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic - * (golden + re-persisted-log diffs, record/refresh write-back, the pinned-header + * (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header * uniformity guard, the fixture guards). Fixtures live under `snapshots//`; * `pnpm run test:snapshot:record` re-records model transcripts against the real - * API; `pnpm run test:snapshot:refresh` rewrites current replay goldens keyless. + * API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless. * See the package README (packages/support/acp-snapshot) and the snapshot RFC, * docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. */ @@ -103,6 +103,7 @@ const SCENARIOS: Scenario[] = [ configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, + { 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 }, @@ -122,6 +123,13 @@ const SCENARIOS: Scenario[] = [ 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 }, @@ -129,7 +137,7 @@ const SCENARIOS: Scenario[] = [ // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). // SessionStart/SubagentStart are excluded because detached injection races log - // order; SubagentStop writes no transcript, so a golden could not prove it ran. + // order; SubagentStop writes no transcript, so an expected output could not prove it ran. // Unit tests cover those points; the hook-snapshot-matrix RFC owns the rationale. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md similarity index 98% rename from examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.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.expected.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.expected.json similarity index 98% rename from examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.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.expected.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/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/bash-spill/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json 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.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl new file mode 100644 index 0000000000..11178b9bc7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl 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.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl new file mode 100644 index 0000000000..321c5499a2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.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/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl 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.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/model-switching/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/model-switching/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/model-switching/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/permission-switching/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/skill-load/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/skill-load/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/text-turn/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/text-turn/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-context/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-context/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl rename to examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.golden.md rename to examples/acp-agent/tests/snapshots/workspace-edit/system-prompt.expected.md diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json b/examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json similarity index 100% rename from examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.golden.json rename to examples/acp-agent/tests/snapshots/workspace-edit/tool-schemas.expected.json diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index ff27347246..dc448b9b23 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -13,7 +13,7 @@ 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 streamExpected = join(scenarioDir, 'stream-json.expected.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)) @@ -134,7 +134,7 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(streamGolden, normalized) - expect(normalized).toBe(await readFile(streamGolden, 'utf8')) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl similarity index 100% rename from examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.golden.jsonl rename to examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index 00a6b4b9a6..4428d5f3a8 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -51,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/tui-agent/README.md b/examples/tui-agent/README.md index b4074881fb..6646649b1b 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -20,4 +20,4 @@ Run `pnpm run demo:code-mode tui` for the sibling 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. +`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 expected terminal cell/style output. 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/tests/snapshots/bash-terminal-card/terminal.golden.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt rename to examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index c0537021e8..534207c875 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -296,7 +296,7 @@ describe('TUI recorded-session terminal snapshots', () => { it(scenario.name, async () => { observedScenarios.add(scenario.name) const result = await runScenario(scenario) - const terminalFile = join(scenarioDir(scenario), 'terminal.golden.txt') + const terminalFile = join(scenarioDir(scenario), 'terminal.expected.txt') if (MODE === 'record' || MODE === 'refresh') { await mkdir(scenarioDir(scenario), { recursive: true }) await writeFile(terminalFile, result.terminal) @@ -317,7 +317,7 @@ afterAll(async () => { for (const scenario of SCENARIOS) { const expected = [ 'session.jsonl', - 'terminal.golden.txt', + 'terminal.expected.txt', ...scenario.seedWorkspace === true ? ['workspace'] : [], ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`), ].sort() 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/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 159effec64..c63f86f797 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -32,6 +32,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/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 950e815ebc..6f9f5f387d 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,13 +8,14 @@ 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()` mutates `agent.session` and 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. 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`. @@ -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 6912f0a0a4..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' @@ -26,35 +25,10 @@ export type { 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 } /** @@ -75,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), }) @@ -84,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' } + }) } /** @@ -104,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,7 +180,7 @@ export class BasicCompactService extends CompactService { break } result = await this.compactRegion(range.start, range.end, agent, signal) - measurement = meter.measure(agent.session, requestHeader) + measurement = meter.measure(agent.session) if (measurement.totalTokens < threshold) return result } 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 01ce91a4e9..0a411440b3 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -3,10 +3,11 @@ import { Context } from 'cordis' 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' @@ -34,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, @@ -60,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, @@ -119,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', () => { @@ -138,6 +150,7 @@ describe('compact configuration and defaults', () => { summarizationModel: '', maxTokens: 8192, compactionRetries: 1, + maxOverflowRetries: 1, auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) @@ -167,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/], @@ -193,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() @@ -217,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, @@ -250,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 () => { @@ -544,7 +611,20 @@ 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( nodes[0]!, @@ -650,6 +730,28 @@ describe('default one-shot summarizer', () => { 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) @@ -688,26 +790,57 @@ describe('default one-shot summarizer', () => { }) 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[] = [] @@ -719,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, @@ -732,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 () => { @@ -761,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 c3f2bb10d9..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,6 +1,7 @@ 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' @@ -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) @@ -95,6 +135,54 @@ function waitForIdle(ctx: Context, agent: Agent): 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 { @@ -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 80ddc00e1a..f0af3c6e74 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -18,7 +18,7 @@ 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`. | +| `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). @@ -61,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 8ec4f59d96..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 diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index d99510baa6..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 @@ -82,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 () => { @@ -115,7 +113,7 @@ describe('CompactService seam', () => { 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 f2c6afe586..06ae13818d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -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[] { 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/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 5d99506c8a..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 } @@ -56,243 +66,540 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agentLoop', summary: 'Concrete agent factory and driver service.', methods: [ - 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', - '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 service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.', methods: [ - 'currentInitiator(): Agent | undefined', - 'requireInitiator(): Agent', - 'withInitiator(agent: Agent, operation: () => T): T', - 'withoutInitiator(operation: () => T): T', - 'setFactory(factory: AgentFactory): () => void', - 'async create(options: CreateAgentOptions): Promise', - 'async resume(options: ResumeAgentOptions): Promise', - 'register(agent: Agent): () => void', - 'enter(agent: Agent, owner: Agent | undefined): () => void', - 'announce(agent: Agent): void', - 'get(id: SessionId): Agent | undefined', - 'isOwnedBy(id: SessionId, owner: Agent): boolean', - 'list(): Agent[]', - 'roots(): 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( 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[]', - 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', - '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 */', + }, ], }, ] @@ -303,240 +610,294 @@ 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.', }, ] @@ -681,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}', 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/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/agent-loop/README.md b/packages/core/agent-loop/README.md index db5861f8c1..e2c7723185 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,11 +50,11 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. +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. @@ -62,7 +62,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded 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` @@ -72,15 +72,45 @@ 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 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ab02bfc13..61b661c082 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -387,7 +387,7 @@ export class ReactLoopAgent implements Agent { [startDriver](): void { if (this._status === 'disposed') return this.driverStarted = true - this.done = this.loopCtx.agents.withInitiator(this, () => 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) }, diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index a153eba7e4..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' @@ -19,27 +19,31 @@ 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 { executeToolCalls } from './tool-calls.ts' -import type { ReactLoopAgent } from './agent.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 } @@ -53,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 } : {} } } @@ -95,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() @@ -138,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) @@ -161,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() @@ -174,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 @@ -186,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) } @@ -262,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 @@ -272,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) @@ -283,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( @@ -302,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()) { @@ -333,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) { @@ -348,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) { @@ -360,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 @@ -454,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 @@ -472,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, @@ -482,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; @@ -526,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 } } @@ -554,86 +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) // 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) => { await executeToolCalls( - ctx, agent, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext, + 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 index 3f3581c70a..663dda1b53 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -4,8 +4,8 @@ * Dispatch may overlap, while policy, results, and result context remain * model-ordered. Abort stops replenishment and drains started calls. * - * Each started call records `tool/call`; `tool/result` commits in model order, - * preserving derived history when audit events interleave with earlier results. + * 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 */ @@ -14,7 +14,6 @@ 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' -import type { ReactLoopAgent } from './agent.ts' /** One tool call after argument parsing, ready to schedule. */ interface PlannedCall { @@ -29,13 +28,21 @@ interface Slot { 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 and rethrows after - * accepting their context into the batch FIFO owned by the caller. + * 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. - * @param agent - agent and session receiving the call lifecycle. + * @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. @@ -45,7 +52,6 @@ interface Slot { */ export async function executeToolCalls( ctx: Context, - agent: ReactLoopAgent, turn: number, step: number, toolCalls: ToolCallBlock[], @@ -53,6 +59,7 @@ export async function executeToolCalls( 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`. @@ -74,7 +81,14 @@ export async function executeToolCalls( const first = planned[next]! const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first] - next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, acceptContext) + 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 + } } } @@ -92,11 +106,11 @@ function parseArguments(raw: string): unknown { * 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, and throws. + * their contexts into the owning batch, records results for skipped calls, and + * returns an aborted outcome. */ async function runGroup( ctx: Context, - session: Session, turn: number, step: number, group: PlannedCall[], @@ -104,9 +118,8 @@ async function runGroup( signal: AbortSignal, maxParallel: number, acceptContext: (context: HookContext) => void, -): Promise { - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) +): 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) @@ -184,19 +197,30 @@ async function runGroup( inFlight.delete(settledIndex) await commitReady() // Abort may arrive while a tool or ordered commit awaits. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (signal.aborted) aborted = true await fillPool() } if (aborted) { - // Started calls and accepted context settle before the turn records the abort. - /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ - throw new Error(String(signal.reason ?? '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 started + 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. */ diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 92eb046788..14e7cf8976 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -12,11 +12,10 @@ 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 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 } from './mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' function driverDone(agent: Agent): Promise { return (agent as Agent & { done: Promise }).done @@ -144,6 +143,59 @@ 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) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 1875ce3757..80d085c32b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -204,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 [ @@ -223,16 +223,24 @@ describe('abort during tool execution ends the turn', () => { 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: '', @@ -244,14 +252,64 @@ 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 () => { diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index ea8dcfd58a..6c0757b460 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -115,14 +115,11 @@ 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(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -134,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()) @@ -144,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') @@ -379,7 +372,7 @@ 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(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -390,20 +383,15 @@ 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 () => { diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 13a63f0f3c..c58479609b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -577,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'), @@ -592,21 +588,19 @@ describe('agent loop', () => { })) 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 () => { 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/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 84c2fb34eb..a77e1d678e 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,6 +1,6 @@ /** * Exercises scheduler ordering and cancellation with deterministic gated tools. - * ACP goldens own transcript-facing coverage. + * ACP expected outputs own transcript-facing coverage. */ import { describe, expect, it } from 'vitest' @@ -469,8 +469,16 @@ describe('tool-call scheduler: abort handling', () => { await waitForIdle(ctx, agent) expect(gated.started).toEqual([]) - expect(events(agent).filter(e => e.type === 'tool/call')).toEqual([]) - expect(events(agent).filter(e => e.type === 'tool/result')).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 () => { @@ -497,9 +505,11 @@ describe('tool-call scheduler: abort handling', () => { await waitForIdle(ctx, agent) expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) - .toEqual([CallId('c1')]) + .toEqual([CallId('c1'), CallId('c2')]) expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId)) - .toEqual([CallId('c1')]) + .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 () => { @@ -525,12 +535,17 @@ describe('tool-call scheduler: abort handling', () => { 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')]) + .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')]) + .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', 'context/message', 'context/message']) + .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']) @@ -566,6 +581,8 @@ describe('tool-call scheduler: abort handling', () => { expect(exclusive).toEqual([]) expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId)) - .toEqual([CallId('c1'), CallId('c2')]) + .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/README.md b/packages/core/agent/README.md index 3cf8bbb6ad..372b594eb7 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age 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. @@ -71,15 +71,31 @@ 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 @@ -90,4 +106,3 @@ The handle every plugin programs against: - **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)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). -- **`agent/pre-step`'s `fullSystemPrompt`/`sessionPrefix` parameters are a flagged smell** — compaction is their only consumer; a lazy prompt provider or a compaction-specific pressure seam is the marked revisit. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index e2f0b77604..702861f407 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -70,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 @@ -185,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. @@ -229,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. @@ -250,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/session/README.md b/packages/core/session/README.md index ba4e8ea94a..07bbc4c078 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -85,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/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 bfd75fdcb6..1acb633a59 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -119,17 +119,25 @@ The agent loop groups consecutive `parallel` calls into a bounded rolling pool a ### 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 @@ -144,11 +152,27 @@ 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 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 1dbda9a08a..9ef18739c8 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -55,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/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index a9c0b08047..76efd77845 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -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 - **Most of the spine set is fixed in code** — `apply()` always mounts the core services and `tool-bash`; config can omit the bundled skills and task-control tools, but swapping the loop or dropping another spine member means composing a different bundle. diff --git a/packages/examples/cli-demo/README.md b/packages/examples/cli-demo/README.md index c938f6c583..760a0f60e1 100644 --- a/packages/examples/cli-demo/README.md +++ b/packages/examples/cli-demo/README.md @@ -55,9 +55,17 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl ### 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. +#### What the model sees -**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. +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 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 ba4fc10101..4eafc9e251 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -79,15 +79,31 @@ 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 terminal submission becomes a user message; submissions made while the agent runs steer the active turn. +#### What the model sees -**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. +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 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 2f116a2b71..16535c446d 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -54,51 +54,91 @@ The package root exports only the Cordis plugin contract (`name`, `inject`, `Con ### 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/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 2927e3f20a..4c868654fc 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -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/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-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/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index a4b43342e4..27bf4b626a 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -42,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 @@ -52,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 f0630f50d9..918c8eee82 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}` } @@ -95,16 +100,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: the stable code and status-line message - // are already captured, so malformed gateway JSON must not mask the 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) + 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/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 4b9c535096..954a0ecebd 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,7 +2,7 @@ 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 { SessionId } from '@deepseek-ai/dsh-session' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' @@ -184,6 +184,32 @@ describe('DeepSeekAdapter against a mock server', () => { ).resolves.toBe(code) }) + 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 () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index cb2abc8205..06395d701c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -43,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. @@ -63,15 +63,31 @@ 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 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/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', () => { diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 2b405c7c91..a2f37ce511 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,5 @@ 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 { toPiContext } from '../src/context.ts' @@ -523,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 039844040e..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 @@ -46,6 +48,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `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`; 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/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 020012a1da..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 { @@ -196,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/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index c9a4d2936b..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 () => { 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 66cddc030a..1a047a8cd7 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. +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. + ## Known Limitations and Deferred Work - **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project. 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/scripts/README.md b/packages/sdk/scripts/README.md index e87c375a4f..2b8a170a3c 100644 --- a/packages/sdk/scripts/README.md +++ b/packages/sdk/scripts/README.md @@ -25,6 +25,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 9fcf537123..ed7a056c69 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -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 diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 11fef96dad..5eb0338f99 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -37,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/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/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index a620051506..0ce06fca2e 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -63,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-fork/README.md b/packages/subagent/subagent-fork/README.md index 244811ab88..5e4b9bf708 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -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-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index ca54dab7c0..0503db9dc4 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -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-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-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index f15defcf2a..5ca9720f00 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -41,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/README.md b/packages/subagent/subagent/README.md index 17edd0e3c7..c810a1f6ce 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -66,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/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 825e252923..8d382b6ce4 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -32,21 +32,45 @@ Foreground and background calls are exclusive. Children may share the parent's w ### 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/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 27e0012d19..2f55ebdfaa 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -5,9 +5,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie 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. +- **`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 expected-output and purity checks, 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). 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. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.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: @@ -36,9 +36,9 @@ 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. +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.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.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. -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). +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 expected outputs, 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). 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). @@ -46,6 +46,10 @@ Constraints: `suite.ts` imports vitest, so the package entry is importable only 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 64e1b0cbd7..9cf18e7824 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 test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory", + "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output 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 e3efca8da9..81e02da6a3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -6,7 +6,7 @@ * 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`, + * stdout (for the expected-output and purity checks) 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. @@ -37,11 +37,10 @@ export type { AgentUnderTest } from './launcher.ts' * (random) session id into a `{{sessionId}}` variable that later steps * reference, since a committed file cannot know the id in advance. * - * `promptAndCancel` sends a prompt WITHOUT awaiting its response, waits until - * the client observes the first streamed `agent_message_chunk` (so the emitted - * frames deterministically precede the cancellation), then cancels the turn — - * the only way to exercise a cancel deterministically (a plain `prompt` step - * awaits the response, which a cancel/hang scenario would block on forever). + * `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 } @@ -49,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 } @@ -158,7 +162,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-')) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) // Fixed path length: spill-policy budgets the preview against the REAL path - // before stdout normalization, so tmpdir() length differences churn goldens. + // before stdout normalization, so tmpdir() length differences churn expected outputs. const spillRoot = '/tmp/dsh-acp-snapshot-spill' // 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. @@ -167,7 +171,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise let sessionLogs: HarvestedLog[] = [] 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 + // Copied into the temp cwd so the agent's bash tools see it; the expected outputs // 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 }) @@ -342,17 +346,19 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on - // its own). To pin frame order deterministically, wait until the client - // has OBSERVED the hang's streamed agent_message_chunk before cancelling — - // so those update frames always precede the cancelled prompt response in - // the transcript (without this, the late chunk and the response race). - // Then cancel and await the prompt, which the bridge settles as - // `cancelled` once the abort propagates. + // 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': { diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index b7267febe7..4d99cc96a2 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -2,7 +2,7 @@ * 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 + * scenario harness ({@link runScenario}), the pure expected-output normalizers * ({@link normalizeStdout} / {@link normalizeSessionLog} / * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite * factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 48761864f0..0c21046eb2 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -60,7 +60,7 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown { } /** - * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable golden + * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable expected output * in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC * `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed. * Invalid JSON throws, doubling as a protocol-stdout purity check. @@ -72,7 +72,7 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown { export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string { const lines = rawStdout.split('\n').filter(line => line.trim().length > 0) // Map each distinct JSON-RPC id (request/response correlate by id) to a stable - // sequence number, in first-seen order, so id churn doesn't perturb the golden. + // sequence number, in first-seen order, so id churn doesn't perturb the expected output. const idSeq = new Map() const stableId = (id: unknown): number => { const key = JSON.stringify(id) @@ -91,7 +91,7 @@ export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): strin } /** - * Normalize a session JSONL log into a stable golden: the header line's + * Normalize a session JSONL log into a stable expected output: the header line's * volatile fields (`createdAt`, `id`, `cwd`) and every event's `time` are * zeroed/scrubbed, all volatile strings scrubbed, and `seq` is LEFT INTACT * (deterministic by contract). Output is JSONL in the same shape as the input — @@ -112,7 +112,7 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri // Event line: zero the epoch-ms timestamp; keep seq (deterministic). record.time = 0 // A hook/result carries the hook's wall-clock runtime (`data.durationMs`), - // which is run-to-run noise like `time` — zero it so the golden reflects + // which is run-to-run noise like `time` — zero it so the expected output reflects // the hook's decision/exit, not how long the shell took. if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') { const data = record.data as Record diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 938676e4b7..60f5cfeadb 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -30,10 +30,10 @@ import { } from './normalize.ts' /** The readable system-prompt snapshot beside each header-pinning fixture. */ -const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.golden.md' +const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' /** The structured tool-schema snapshot beside each header-pinning fixture. */ -const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.golden.json' +const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json' /** Stable session-log token standing in for the sidecar's initial schemas. */ const TOOLS_TOKEN = '{{tools}}' @@ -41,7 +41,7 @@ const TOOLS_TOKEN = '{{tools}}' /** A snapshot scenario and how its fixtures are produced. */ export interface Scenario { name: string - /** Whether the scenario drives at least one model turn (so a JSONL golden applies). */ + /** Whether the scenario drives at least one model turn (so a JSONL expected output applies). */ hasModelTurn: boolean /** * Whether the run persists a comparable session log to diff against the @@ -112,8 +112,8 @@ export interface SnapshotSuiteOptions { scenarios: Scenario[] /** * `replay` (keyless, the default tier), `record` (live API; re-records the - * `recorded` scenarios' fixtures and refreshes the Vitest goldens under - * `--update`), or `refresh` (keyless replay that rewrites stdout goldens and + * `recorded` scenarios' fixtures and refreshes the Vitest expected outputs under + * `--update`), or `refresh` (keyless replay that rewrites stdout expected outputs and * comparable session fixtures from the replay run). The caller derives this * from `$DSH_SNAPSHOT` — env reading stays outside this library. */ @@ -427,7 +427,7 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement } /** - * Register the suite: one test per scenario (the golden/log compares and + * Register the suite: one test per scenario (the expected-output and log comparisons and * the header-uniformity guard) plus the fixture guard block (no orphan * scenario dirs, required files present, exactly one pin per header class, * pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning @@ -467,7 +467,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones // (sidecar-driven errors/cancel) are never re-recorded. - it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the goldens`, async ({ expect }) => { + it.skipIf(RECORDING && !scenario.recorded)(`snapshot: ${scenario.name} matches the expected outputs`, async ({ expect }) => { const dir = join(snapshotsDir, scenario.name) const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') @@ -573,9 +573,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const stdout = normalizeStdout(result.rawStdout, ctx) if (REFRESHING) { - await writeFile(join(dir, 'stdout.golden.jsonl'), stdout) + await writeFile(join(dir, 'stdout.expected.jsonl'), stdout) } - await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl')) + await expect(stdout).toMatchFileSnapshot(join(dir, 'stdout.expected.jsonl')) // A model turn always produces a log worth comparing; a hook scenario can // produce one without a model turn (a `rejected` turn carrying `hook/*`). @@ -651,7 +651,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { describe('snapshot fixtures', () => { it('every scenario directory is registered (no orphans)', async () => { - // toMatchFileSnapshot does not prune orphaned golden/fixture files, so a + // toMatchFileSnapshot does not prune orphaned expected-output or fixture files, so a // renamed/removed scenario could leave a stale dir that nothing exercises. // Fail loud on any snapshots/ not present in the scenario table. const entries = await readdir(snapshotsDir, { withFileTypes: true }) @@ -665,7 +665,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { 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) + expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true) expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``) .toBe(overridden === true) 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 5dd5524ed0..43b5ee3fb3 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -33,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). */ @@ -126,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, @@ -229,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: diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/record-suite/rec-child/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/system-prompt.golden.md b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/system-prompt.expected.md similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/system-prompt.golden.md rename to packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/system-prompt.expected.md diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.golden.json b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.expected.json similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.golden.json rename to packages/support/acp-snapshot/tests/fixtures/record-suite/rec-pin/tool-schemas.expected.json diff --git a/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/record-suite/rec-skip/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/authored-error/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/authored-error/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/suite/authored-error/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/suite/blocked-log/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/no-model/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/no-model/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/no-model/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/suite/no-model/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/system-prompt.golden.md b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/system-prompt.expected.md similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/system-prompt.golden.md rename to packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/system-prompt.expected.md diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.golden.json b/packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.expected.json similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.golden.json rename to packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/tool-schemas.expected.json diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.golden.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl similarity index 100% rename from packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.golden.jsonl rename to packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 2b9d6e032f..3d174b3c3e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -334,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( diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 92e676b1d6..ac6f944a4a 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -24,7 +24,7 @@ import { /** * Unit tests for the suite factory, by running it: two synthetic suites over the scripted fake * ACP bin (./fixtures/fake-acp-agent.ts) register real describe/it trees at collection time, - * so every factory path — golden and log compares, the per-suite header pin and its uniformity + * so every factory path — expected-output and log comparisons, the per-suite header pin and its uniformity * guard, record-mode fixture write-back, skip semantics, and the fixture guard block — * executes as an ordinary green test. * @@ -61,7 +61,7 @@ const RECORD_SCENARIOS: Scenario[] = [ // Record/refresh modes mutate their snapshots dir, so run them on throwaway // copies — except record's documented bootstrap knob, which regenerates the -// committed record fixtures/goldens in place. +// committed record fixtures and expected outputs 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) { @@ -80,9 +80,9 @@ afterAll(async () => { }) function staleRefreshFixtures(dir: string): void { - writeFileSync(join(dir, 'plain-turn', 'stdout.golden.jsonl'), 'stale stdout\n') - writeFileSync(join(dir, 'pin-turn', 'system-prompt.golden.md'), 'STALE PROMPT\n') - writeFileSync(join(dir, 'pin-turn', 'tool-schemas.golden.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n') + writeFileSync(join(dir, 'plain-turn', 'stdout.expected.jsonl'), 'stale stdout\n') + writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n') + writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n') const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json') const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record @@ -117,7 +117,7 @@ describe('defineAcpSnapshotSuite: refresh mode', () => { describe('defineAcpSnapshotSuite: refresh write-back', () => { it('rewrites stdout and comparable logs from a replay-mode child run', () => { - const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.golden.jsonl'), 'utf8') + const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.expected.jsonl'), 'utf8') expect(stdout).not.toContain('stale stdout') expect(stdout).toContain('env:{\\"mode\\":\\"replay\\"') expect(stdout).not.toContain('\\"mode\\":\\"refresh\\"') @@ -130,7 +130,7 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { expect(authored).toContain('"error":"model exploded"') expect(authored).not.toContain('"error":"stale"') - expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.golden.md'), 'utf8')).toBe([ + expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.expected.md'), 'utf8')).toBe([ 'SYS PROMPT', '', '', @@ -140,7 +140,7 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { 'NEW PROMPT LINE', '', ].join('\n')) - const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.golden.json'), 'utf8') + const schemas = readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8') expect(schemas).toContain('"description": "D1"') expect(schemas).not.toContain('"name":"stale"') }) @@ -195,7 +195,7 @@ describe('defineAcpSnapshotSuite: registration contract', () => { describe('sessionFixtureNames', () => { it('orders the primary and contiguous child fixtures while ignoring other files', () => { expect(sessionFixtureNames([ - 'stdout.golden.jsonl', + 'stdout.expected.jsonl', 'session.2.jsonl', 'session.jsonl', '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 b256ef9a59..4c8966333b 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -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/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 0b783efc0d..450f6f6f61 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -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/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/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/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/tool-todo/README.md b/packages/todo/tool-todo/README.md index f6097abe96..45220706eb 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -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/ui/acp/README.md b/packages/ui/acp/README.md index 4e19313302..8642e33d6b 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -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/snapshot-replay.md b/packages/ui/acp/snapshot-replay.md index 4242f2406d..9c716148aa 100644 --- a/packages/ui/acp/snapshot-replay.md +++ b/packages/ui/acp/snapshot-replay.md @@ -12,14 +12,14 @@ sequenceDiagram participant Workspace participant Replay as llm-replay adapter participant ACP as acp-agent subprocess - participant Golden as stdout golden + participant Expected as stdout expected output Recorder->>Fixture: session.jsonl + workspace inputs Fixture->>Workspace: seed files and hook configs Fixture->>Replay: recorded StreamChunk script Replay->>ACP: deterministic llm/stream chunks ACP->>Workspace: bash, fs, and hook side effects - ACP->>Golden: normalized sessionUpdate stream - Golden-->>ACP: diff must be empty + ACP->>Expected: normalized sessionUpdate stream + Expected-->>ACP: diff must be empty ``` The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text. 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 ea6b71a7c8..856c933cbc 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -26,9 +26,17 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu ### 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/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 caaa779c6e..eda7d00b8b 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -25,15 +25,31 @@ The plugin seeds display labels from the live agent registry, then tracks `agent ### 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 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":["