From c45d7927cf779ab113522794cddd536a3c04f271 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:24:32 +0800 Subject: [PATCH] docs: tighten prose audit after master retarget --- .agents/skills/dsh-code-review/SKILL.md | 2 +- .agents/skills/dsh-doc-standards/SKILL.md | 6 +- docs/AGENTS.md | 2 + docs/architecture.md | 4 +- docs/config-catalog.md | 99 +--- docs/cookbook/adding-a-tool.md | 11 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cordis-catalog/events.md | 116 ++--- docs/cordis-catalog/services.md | 44 +- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/compaction.md | 4 +- docs/core-data-structures/core.md | 10 +- docs/core-data-structures/filesystem.md | 2 +- docs/core-data-structures/skills.md | 4 +- docs/core-data-structures/subagent.md | 10 +- docs/core-data-structures/tools.md | 6 +- docs/core-data-structures/web.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- docs/event-producer-consumer.md | 62 +-- docs/persistence-catalog.md | 36 +- .../0001-acp-default-export-drops-inject.md | 4 +- docs/rfc/README.md | 2 +- .../2026-06-11-content-block-vocabulary.md | 4 +- .../2026-06-13-twin-llm-adapters.md | 2 +- .../2026-06-14-session-persistence.md | 2 +- .../2026-06-15-turn-enclosure-invariant.md | 2 +- .../2026-06-17-filesystem-capability-seam.md | 2 +- ...-18-agent-lifecycle-and-ownership-seams.md | 17 +- .../2026-06-18-session-surface.md | 4 +- ...18-shared-persistence-write-coordinator.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 10 +- ...2026-06-20-extract-example-app-packages.md | 13 +- ...06-21-mandatory-app-attribution-headers.md | 2 +- .../2026-06-24-web-capability-seam.md | 6 +- .../2026-06-26-file-context-as-event-gate.md | 12 +- ...0-bash-stdin-env-trusted-plugin-surface.md | 8 +- .../2026-07-02-fs-per-session-cwd.md | 2 +- ...26-07-02-result-time-applied-hunk-diffs.md | 6 +- ...t-variables-and-tool-guidance-ownership.md | 15 +- .../2026-07-05-reconstructable-requests.md | 10 +- ...7-05-subagent-provider-lifecycle-events.md | 2 +- .../2026-07-06-timeout-deadline-library.md | 6 +- .../2026-07-07-tool-call-timeout-policy.md | 2 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- .../feature/2026-06-15-code-mode.md | 42 +- ...6-06-18-acp-terminal-and-tool-rendering.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 17 +- .../2026-06-21-subagent-capability-seam.md | 11 +- .../2026-06-22-acp-subagent-backend.md | 12 +- .../feature/2026-06-29-todo-write-tool.md | 2 +- .../feature/2026-06-30-hook-bridges.md | 6 +- .../feature/2026-06-30-hook-protocol-lib.md | 4 +- .../feature/2026-06-30-interception-seams.md | 6 +- .../2026-06-30-session-store-fork-api.md | 2 +- .../feature/2026-07-05-dynamic-workflows.md | 16 +- .../feature/2026-07-06-approval-seam.md | 36 +- .../feature/2026-07-06-explicit-tool-order.md | 9 +- .../implemented/feature/2026-07-06-sandbox.md | 38 +- .../feature/2026-07-07-session-prefix.md | 4 - .../feature/2026-07-08-repeat-tool-guard.md | 9 +- ...6-07-08-self-referential-cordis-toolset.md | 14 +- .../2026-06-20-generated-cordis-catalog.md | 2 +- .../process/2026-07-02-tool-schema-catalog.md | 6 +- ...26-07-04-cordis-jsdoc-completeness-gate.md | 2 +- .../2026-07-04-doc-tiers-and-budgets.md | 10 +- .../2026-07-04-persistence-log-catalog.md | 2 +- .../process/2026-07-05-uniform-rfc-format.md | 2 +- .../2026-07-06-export-surface-jsdoc-gate.md | 2 +- .../2026-07-06-generated-config-catalog.md | 6 +- .../2026-07-06-parallel-github-ci-gates.md | 6 +- ...6-20-collapse-trace-only-session-events.md | 2 +- ...-drop-unconsumed-llm-assembled-surfaces.md | 2 +- .../2026-06-20-prune-dead-seam-methods.md | 2 +- .../2026-06-20-public-agent-stop-surface.md | 2 +- ...-20-remove-agent-boundary-mirror-events.md | 26 +- .../2026-07-04-drop-image-content-block.md | 8 +- ...drop-unconsumed-web-observation-surface.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- ...2026-07-04-remove-agent-steering-mirror.md | 2 +- .../2026-07-04-share-app-bin-boot-glue.md | 2 +- ...26-07-04-tighten-hook-protocol-contract.md | 6 +- ...-04-trim-acp-bridge-unreachable-surface.md | 4 +- .../testing/2026-06-19-acp-snapshot-tests.md | 29 +- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- ...0-remove-redundant-snapshot-log-goldens.md | 2 +- ...6-06-22-fork-child-replay-seed-boundary.md | 2 +- .../2026-06-22-subagent-snapshot-replay.md | 2 +- ...6-07-04-single-source-acp-replay-config.md | 2 +- .../2026-07-08-shared-acp-snapshot-package.md | 8 +- ...claude-code-and-codex-subagent-backends.md | 16 +- .../2026-06-20-discover-package-inventory.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- ...026-07-04-prune-dead-core-spine-surface.md | 2 +- ...prune-unimplemented-subagent-vocabulary.md | 2 +- docs/tool-execution-pipeline.md | 2 +- examples/AGENTS.md | 10 +- examples/README.md | 2 +- examples/acp-agent/README.md | 2 +- examples/coding-agent/README.md | 2 +- examples/coding-agent/tests/code-mode.e2e.ts | 10 +- packages/AGENTS.md | 5 +- packages/bash/bash-local/README.md | 2 +- packages/bash/bash-sandbox/README.md | 2 +- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 12 +- packages/bash/bash/src/index.ts | 7 +- packages/bash/bash/src/types.ts | 10 +- packages/bash/tool-bash/README.md | 10 +- packages/compact/compact-basic/README.md | 17 +- .../compact-basic/tests/compact-basic.spec.ts | 10 +- packages/cordis/tool-cordis/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 52 +- packages/core/agent-core/README.md | 2 +- packages/core/agent-loop/README.md | 64 +-- packages/core/agent-loop/src/agent.ts | 86 +--- packages/core/agent-loop/src/index.ts | 9 +- packages/core/agent-loop/src/loop.ts | 464 +++-------------- packages/core/agent-loop/tests/agent.spec.ts | 21 +- .../agent-loop/tests/review-fixes.spec.ts | 73 +-- packages/core/agent/README.md | 14 +- packages/core/agent/src/dispatch.ts | 24 +- packages/core/agent/src/index.ts | 173 +------ packages/core/agent/src/types.ts | 474 +++--------------- packages/core/scope/README.md | 4 +- packages/core/scope/src/index.ts | 10 +- packages/core/session/README.md | 30 +- packages/core/session/src/index.ts | 68 +-- packages/core/session/src/json.ts | 57 +-- packages/core/session/src/repair.ts | 6 +- packages/core/session/src/surface.ts | 8 +- packages/core/session/src/types.ts | 57 +-- packages/core/system-prompt/README.md | 4 +- packages/core/system-prompt/src/index.ts | 350 +++---------- packages/core/tools/README.md | 64 +-- packages/core/tools/src/index.ts | 321 ++---------- packages/core/tools/src/presentation.ts | 10 +- packages/core/tools/src/schema.ts | 56 +-- packages/core/tools/tests/tools.spec.ts | 11 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs/src/index.ts | 6 +- packages/fs/tool-fs/tests/harness.ts | 12 +- packages/guard/repeat-tool-guard/README.md | 2 +- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/src/events.ts | 8 +- packages/hooks/hook-protocol/src/types.ts | 10 +- packages/hooks/hooks-codex/src/config.ts | 7 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/never.ts | 6 +- packages/llm/llm/src/types.ts | 11 +- packages/sandbox/sandbox-local/README.md | 8 +- packages/sandbox/sandbox-local/src/index.ts | 23 +- .../session-persistence-jsonl/README.md | 4 +- .../session-persistence-sqlite/README.md | 4 +- .../session-persistence/src/coordinator.ts | 92 +--- .../session-persistence/src/index.ts | 86 +--- .../tests/coordinator-contract.ts | 20 +- packages/skill/skill/README.md | 6 +- packages/skill/skill/src/index.ts | 32 +- packages/subagent/subagent-acp/src/run.ts | 132 +---- .../subagent-inprocess/src/structured.ts | 47 +- .../tests/structured.spec.ts | 20 +- .../tests/subagent-spawn.spec.ts | 11 +- packages/subagent/subagent/src/types.ts | 32 +- packages/subagent/tool-subagent/src/index.ts | 43 +- packages/support/acp-snapshot/README.md | 4 +- packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 17 +- .../invariants/tests/invariants.spec.ts | 7 +- packages/support/llm-replay/src/index.ts | 16 +- packages/support/subagent-mock/src/index.ts | 7 +- packages/ui/acp/README.md | 39 +- packages/ui/acp/src/codec.ts | 9 +- packages/ui/acp/src/index.ts | 239 ++------- packages/ui/acp/tests/dispose.spec.ts | 113 +---- packages/ui/acp/tests/turns.spec.ts | 38 +- packages/ui/app-boot/src/index.ts | 8 +- .../ui/stdio-agent/tests/built-bin.e2e.ts | 10 +- packages/ui/user-approval/README.md | 12 +- packages/ui/user-approval/src/index.ts | 101 +--- packages/util/timeout/src/index.ts | 11 +- .../workflow-workerthread/src/host.ts | 78 +-- .../workflow-workerthread/src/runtime.ts | 38 +- .../tests/workflow-workerthread.spec.ts | 22 +- packages/workflow/workflow/src/index.ts | 88 +--- scripts/demo-code-mode.mjs | 10 +- scripts/doc-budgets.manifest.json | 4 +- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-persistence-catalog.ts | 10 +- scripts/verify-export-jsdoc.ts | 8 +- scripts/verify-mermaid.ts | 13 +- 192 files changed, 1047 insertions(+), 4078 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 01d76ca439..cae1c247cd 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -Read the diff and enough surrounding code to understand the design, then verify suspected defects before reporting them. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index c170a9391b..3e6da80c94 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -25,13 +25,13 @@ Run the placement test in the standard's taxonomy table, then check the constrai ## Auditing the corpus -The audit is a hunt for the standard's slop checklist, cheapest probes first: +The audit is a hunt for the standard's slop checklist, cheapest probes first. Establish the PR's current base first; after a retarget or base merge, repeat the audit for prose introduced by the new base rather than relying on the earlier result. 1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' | grep -v '^vendor/' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. 2. Hunt narrated history: `rg -n -g '!vendor' "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts'` and keep only contrasts against a live alternative. -3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, and rejected local alternatives. Preserve only a non-obvious contract or durable rationale; otherwise delete the comment. +3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, rejected local alternatives, and the same rationale repeated beside sibling methods. Preserve only a non-obvious contract or durable rationale; otherwise delete the comment. 4. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links. -5. Replace hand-written catalog or JSDoc restatements with links to generated references. +5. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference. 6. In `implemented/` RFCs, remove migration plans, test checklists, and future-tense spec language; keep the decision, rationale, and shipped constraints. 7. If removing prose changes a promised behavior rather than its explanation, use a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md)). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fdee984f36..407c031741 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -55,7 +55,9 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar - A war story told inline where a one-line rule plus a postmortem/RFC link would do. - Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it. - Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead. +- Hand-maintained inventories of tests, packages, or implementation status when the tree or a generator is authoritative. - Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it. +- The same rationale repeated beside sibling methods. State it once at the owning seam or shared helper. - Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home. - Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior. - Spec-speak in `implemented/` RFCs: "should", migration plans, acceptance checklists. An implemented RFC describes what is, per [rfc/implemented/AGENTS.md](rfc/implemented/AGENTS.md). diff --git a/docs/architecture.md b/docs/architecture.md index a1f0cae9a8..84515910b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,7 @@ forever: checkpoint persistence and notify idle/running status ``` -Prompt assembly is single-path: the loop sends `renderPrompt(await assemble(assembleContextFor(agent)))`; the helper couples the explicit agent and scope. Plugins contribute ordered sections, tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's global default persona (order 0, shadowable by a same-named agent-scoped section) — while the loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and strict `{{name}}` variables. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. @@ -109,7 +109,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Scope -Every live agent owns `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), keyed by the agent). Its registrations are visible only to that agent, shadow same-named globals, and unwind with it. Its listeners hear only that agent's dispatches; an opaque carrier routes while the real subject stays explicit. `CreateAgentOptions.setup(agentCtx)` composes this world before publication and does not drive. Dev invariants and `verify-scoped-dispatch` keep carrier/subject identity aligned with event declarations. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent `persona`, `toolFilter`, and `maxDepth` are the separate [composition-controls feature](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). ## State diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3d4e6b22f9..5d4851128c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -18,20 +18,14 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } ``` Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:203`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -124,7 +118,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -401,7 +395,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:312`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:300`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -453,16 +447,7 @@ export interface Config { * own failure dialect. */ runnerFailureSignatures?: string[] - /** - * Per-probe timeout in milliseconds for the chain's functional probes - * (default: 5000; must be a positive finite number — Node treats a 0 - * `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A - * probe that exceeds it reads as an unusable rung, so a - * host slow enough to trip the default — cold NFS mounts, heavily loaded - * CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no - * config escape. Bounds ONE probe, and the chain walk runs each at most once - * per provider lifetime. - */ + /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ probeTimeoutMs?: number } ``` @@ -634,16 +619,7 @@ export interface Config { disposeGraceMs?: number } -/** - * How the client answers a child's `session/request_permission`. The first cut - * does not surface permission prompts to a human, so every request is - * auto-answered by this fixed policy: - * - * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child - * that asks before a side effect does not get to take it. - * - `allow` — approve every prompt by selecting its first `allow_*` option (or, - * if none is offered, `cancelled`). Use when the child is trusted to act. - */ +/** Fixed response to child permission requests: reject, or first allow option. */ export type PermissionPolicy = 'allow' | 'reject' ``` @@ -696,7 +672,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:90`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:85`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -718,48 +694,20 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagen /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { /** - * The deployment's persona — the ONE deployment-authored fragment of the - * system prompt, rendered as the order-0 `deployment:persona` section - * (after the harness identity, before all tool guidance). Every agent in - * the context shares it by default; a per-agent persona is a SCOPED section - * of the same name registered through that agent's `agent.ctx` (it shadows - * this one for that agent — the subagent seam's `persona` request field does - * exactly that). Template, not free-form text: - * every complete `{{…}}` group is interpreted strictly against the - * registered prompt variables (the shipped agent loop registers `{{model}}` - * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose - * yet (a deliberate deferral; see the prompt-variables RFC). Defaults to - * `''` — the empty section is dropped at render, so a persona-less - * deployment opens with the harness identity alone. + * Deployment-wide order-0 persona template. A scoped section named + * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string /** - * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed - * tools take their listed position, and tools absent from the list are - * inserted at the {@link TOOL_ORDER_REST} (`''`) entry in - * lexicographic name order. A configured list must contain the rest entry - * exactly once, no duplicate names, and no name without a registered tool — - * a misconfigured order blocks work instead of silently reaching a model - * request: shape violations throw at load, and an unregistered name rejects - * every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may - * not be a collected tool name; such a provider output also rejects the - * assembly. The single assembly-time validation rejects either failure - * before any model request — the earliest moment the registered tool set - * exists to check against, since tool plugins register after this service - * constructs. When omitted, tools are ordered lexicographically by name. - * Applied to the tools - * {@link SystemPrompt.assemble} collects, BEFORE the - * `system-prompt/assemble` waterfall — like the sections' `order` sort, it - * canonicalizes what the registry contributed (registration order is a - * plugin-load artifact); a waterfall listener that mutates the tool list - * owns the determinism of what it emits. Rationale (and why not per-plugin - * weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md. + * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. + * Shape errors fail at load and unknown names fail at assembly. Omitted means + * lexicographic order. See the explicit-tool-order RFC for rationale. */ toolOrder?: string[] } ``` -Source: [`packages/core/system-prompt/src/index.ts:225`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:145`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -872,7 +820,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:19`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` @@ -919,20 +867,7 @@ Requires: `systemPrompt` ```ts config-catalog /** Plugin config: how the registered tools are presented to the model. */ export interface Config { - /** - * The presentation mode. `'native'` (the default) contributes every - * visible end capability as a native wire function definition. Under - * `'code'` this registry contributes exactly ONE wire tool, - * `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a - * TypeScript API the program calls. `'both'` contributes every native - * definition AND `run_code` + the SDK section. Non-native modes require a - * loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing - * or mismatched runtime rejects every prompt assembly with an actionable - * error (misconfiguration fails loud, before any model request). A - * configured `systemPrompt.toolOrder` naming native tools likewise rejects - * every assembly under `'code'` (those names are no longer contributed) — - * a deployment switching modes updates its order config or drops it. - */ + /** Model presentation: native schemas, `run_code` plus SDK, or both. Code modes require a TypeScript runtime. */ mode?: ToolPresentationMode } @@ -940,7 +875,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:401`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:300`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -971,7 +906,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:270`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:213`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 2912fde6e4..7971d0a45b 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -36,7 +36,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w - **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input. - **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline. -- **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means). +- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them. - **Honor `exec.signal`.** Cancel in-flight work when it fires. - **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`. - **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: ''}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch). @@ -53,7 +53,7 @@ Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for ## Code Mode reaches your tool for free -Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), each visible registered capability is callable from a `run_code` program as `await tools.(args)` — nothing to add. The registry keeps `run_code` itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`); each program call receives its own immutable execution whose `parent` is the enclosing `run_code` token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design `description` and parameter `description`s as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca). +In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The SDK derives parameters from the same JSON Schema, and calls re-enter the normal execution pipeline. Write descriptions as model-facing API docs; non-text result blocks become placeholders in programs. ## How your tool renders in an editor (ACP presentation) @@ -65,7 +65,10 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it. - `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.) - `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.) -- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create; `write`/`edit` attach the hunks via the `meta` channel and read them back here). A mutation tool returns the `diff` result even when it duplicates the call-time card, because an ACP `tool_call_update.content` REPLACES the call's content — a non-diff result would clobber the pending diff. `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card. +- `presentResult(args, { content, isError, meta? })` returns the completed card: + - `generic` supplies an optional title and content. + - `terminal` supplies raw output and optional exit metadata; the bridge renders the capability-specific or fenced fallback view. + - `diff` supplies applied hunks, often carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content. Hard rules (they bite if broken): @@ -77,4 +80,4 @@ The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a too ## Tests every tool needs -Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path. +Cover argument rejection, result shaping, and HMR disposal. Side-effecting tools also need an agent-loop integration test that asserts session events. Editor presentation needs exact unit coverage plus an ACP snapshot; terminal cards must exercise a client with `terminalOutput: true`. diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e2677e463a..a2aa255ccf 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { ## A client-driver plugin (external protocol bridge) -A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: correlate and settle each request exactly once from the durable `turn/end` session event even if rendering fails, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it. +A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence. `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index d950ec3a25..e206045fad 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback. +A fully configured agent and its session were published. Synchronous listener failure vetoes publication; asynchronous failure is reported. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:127`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit -An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs. +An agent left the registry. AgentLoop emits this after driver quiescence; custom registry users own their driver-ordering contract. ```ts cordis-catalog 'agent/disposed'(this: Scoped, agent: Agent): void @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:135`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,13 +47,11 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial -Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet. - -Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped`), built by the emitting side via `scopeTarget`/`agentEvents`. +Awaited checkpoint before `step/start` for outside-step surface mutations. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void @@ -61,11 +59,11 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall -Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit. +Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise @@ -73,11 +71,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:190`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit -A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options. +Detached, frozen content entered the agent's inbox. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -85,11 +83,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:152`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall -Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit. +Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise @@ -97,15 +95,11 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall -Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests. - -This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter. - -The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped`), built by the emitting side via `scopeTarget`/`agentEvents`. +Compose the frozen session-stable request prefix once per loop instance. Interrupted composition is discarded; changing context belongs in history. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -113,11 +107,11 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit -The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts. +The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. ```ts cordis-catalog 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void @@ -125,11 +119,11 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns. +Agent status changed (`idle` ⇄ `running`, or → `disposed`). ```ts cordis-catalog 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void @@ -137,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,11 +143,11 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall -Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override. +Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise @@ -161,11 +155,11 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial -Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn. +Monotonic terminal-stop checkpoint after continuation and steering are folded. A stop discards pending steering. ```ts cordis-catalog 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -173,13 +167,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) ## `approval/*` ### `approval/request` — waterfall -Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is a readonly same-process value borrowed from the caller. +Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise @@ -187,7 +181,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:70`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) ## `fs/*` @@ -245,27 +239,27 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced detach does not remove the entry immediately: removal and the paired `session/disposed` edge wait until the creation dispatch unwinds. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session. +Emitted after session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit -A previously announced session left the store. Emitted exactly once on normal detach or publication rollback, and never for a prepared/entered session whose `session/created` announcement did not begin. Listener failures (including returned-promise rejections) are logged and contained per listener so teardown always reaches quiescence. Scope-filtered dispatch uses the same owner carrier captured at entry; agent-scoped listeners hear only their own session's teardown. +Emitted once when an announced session leaves the store, including publication rollback. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) ### `session/event` — emit -An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. The log push is the commit point; synchronous throws and returned-promise rejections from observers are logged and contained per listener, so they cannot make a committed append appear to fail or starve later listeners. The exact callback list and Cordis internal-dispatch checks resolve before the push; callbacks themselves run only after it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session. +Post-commit append feed. Observer failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog 'session/event'(this: Scoped, session: Session, event: SessionEvent): void @@ -273,17 +267,17 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel -Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the caller waits for all of them, but none can veto. Dispatch it through SessionStore.flush — the store owns the carrier — never via a raw `ctx.parallel`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session. +Awaited parallel durability checkpoint; dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) ## `skill/*` @@ -353,27 +347,23 @@ Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/s ### `system-prompt/assemble` — waterfall -Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate. - -Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `context.scope` — a listener registered through `agent.ctx` fires only for that agent's assemblies; a plain plugin listener fires for every assembly (scope-less ones included, dispatched subject-less). - -The returned assembly is authoritative. This is an expert composition seam: a listener that removes or replaces another plugin's protocol contribution owns preserving that protocol's invariants. +Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. ```ts cordis-catalog 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:49`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/system-prompt/src/index.ts) ### `system-prompt/change` — emit -A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope. ```ts cordis-catalog 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:59`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/system-prompt/src/index.ts) ## `tools/*` @@ -385,11 +375,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall -Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which tool and scope the pipeline accepted. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less). +Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -397,11 +387,11 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:128`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts) ### `tools/post-execute` — waterfall -Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. Core tool dispatch runs earlier as the base `next()` of the `tools/execute` waterfall, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` fires only for that agent's calls; a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). +Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise @@ -409,11 +399,11 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts) ### `tools/pre-execute` — waterfall -Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less). +Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise @@ -421,11 +411,11 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts) ### `tools/result` — emit -Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline. +Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. ```ts cordis-catalog 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined @@ -433,7 +423,7 @@ Synchronous notification of the authoritative FINAL tool outcome, after the comp Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts) ## `workflow/*` @@ -445,7 +435,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:96`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) ### `workflow/agent-start` — emit @@ -455,7 +445,7 @@ One `agent()` call established a ready child run. Paired with Events['workflow/a 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) ### `workflow/end` — emit @@ -465,7 +455,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) ### `workflow/log` — emit @@ -475,7 +465,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) ### `workflow/phase` — emit @@ -485,7 +475,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) ### `workflow/start` — emit @@ -495,7 +485,7 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) ## Inherited events (cordis core + loader/hmr/timer) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 42093357be..f36984364d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:331`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -38,13 +38,11 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:131`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` -The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here. - -Owns the policy tier too (`effective = fold(the session's 'approval/policy' events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'` before dispatching any interactive answerer, a per-agent prompt section states a `'never'` policy (and only that one in prose — an `'ask'` promise could overclaim an answerer that headless compositions do not have), and an `agent/pre-step` narrator injects at most one coalesced notice when a session's effective policy moved past what the model was last told. +Approval request and policy service. It logs each ask/outcome pair, applies session policy before answerers, and exposes deterministic policy changes to the model through prompt and pre-step notices. ```ts cordis-catalog async request(req: ApprovalRequest): Promise @@ -52,7 +50,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:294`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:228`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -145,14 +143,7 @@ Source: [`packages/sandbox/sandbox/src/index.ts:109`](../../packages/sandbox/san ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) -Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). - -Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF): - -- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded. -- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn). -- **JSON-serializable events.** `SessionEventMap` is merge-extensible, so append materializes each complete batch through the shared lossless-JSON boundary before buffering it. The public `session.events` view is immutable, but persistence still snapshots direct/replay callers at this independent trust boundary. -- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization). +Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog abstract create(meta: SessionHeader): Promise @@ -163,7 +154,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:59`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -182,7 +173,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:550`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -212,7 +203,7 @@ Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/ ## `ctx.systemPrompt` — `SystemPrompt` -Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona). +Registry service for the prompt inputs assembled before each model step. ```ts cordis-catalog section(section: PromptSection): () => void @@ -221,13 +212,11 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:340`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:210`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` -Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section. - -Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds the registry's prompt contribution, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so those registry-owned presentation and dispatch paths agree. An expert `system-prompt/assemble` listener may deliberately replace the final wire composition and owns any resulting divergence. +Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog register(definition: ToolDefinition): () => void @@ -240,7 +229,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:351`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -277,20 +266,13 @@ Source: [`packages/web/web/src/index.ts:79`](../../packages/web/web/src/index.ts ## `ctx.workflows` — `WorkflowService` (abstract seam) -Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). - -Semantics every implementation must honor: - -- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation). -- The `workflow/*` events fire through emitWorkflowEvent (borrowed immutable data, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles. -- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind). -- Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it. +Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. ```ts cordis-catalog abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:211`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:152`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 51f7cb0696..82579edf5a 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -108,7 +108,7 @@ interface BashExecSpec { The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. -`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +Trusted in-process plugins use `stdin` and `env` for hook payloads and hook-specific variables. The model-facing bash tool does not expose either because shell syntax already provides equivalent input. This is not a security boundary: `dsh-bash-local` scrubs ambient credential variables, then overlays explicit caller entries. See [the bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 2f402be57d..d61de7bf6f 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction and `compactRegion(...)` for an explicit surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. -Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy. +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. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 757c74f399..105d9c7f35 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -201,7 +201,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws. +The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative tool order, and session prefix through `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) and the [reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). + +`agent/request` may replace the frozen call config. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records its result. Requests reaching `llm/stream` are deep-frozen. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. @@ -353,7 +355,9 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. + +The [event taxonomy](../architecture.md#event-taxonomy) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. ## Interception decisions @@ -396,7 +400,7 @@ type ContinuationStop = Extract type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` -`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself. +`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides. ## `ToolDefinition` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index bbc55be966..50248f9b81 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,6 +1,6 @@ # Filesystem -The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas. +The filesystem stack has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events, and [dsh-tool-fs](../../packages/fs/tool-fs) executes model-facing read/write/edit calls and renders windows. Alternate backends do not change policy or tool schemas. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 56ac91a6da..93189ba2cb 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -6,7 +6,9 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind ## Provider registry -`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Provider objects, lookup options, and candidates are readonly same-process contracts, so the registry borrows them instead of manufacturing defensive snapshots. The registry still validates semantic fields, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract. +`ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated. + +Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. ```ts type-equiv interface SkillProvider { diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5454f326b3..478dbff467 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -21,7 +21,7 @@ interface SubagentCapabilities { ## The start request -What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The four optional fields (`outputSchema`, `maxDepth`, `toolFilter`, `persona`) each gate on the matching `SubagentCapabilities` flag — in-process backends realize `toolFilter` as a scoped `tools.restrict()` and `persona` as a scoped shadowing `deployment:persona` section, both composed in the child's creation window. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)). +The service validates this request against the named provider's capabilities before `start`. `parent` supplies working-directory, lineage, and depth context. Optional output schema, depth, tool filter, and persona require matching capability flags. In-process backends scope filters and personas to child creation and implement the supported object-rooted output-schema subset with a forced capture tool. ```ts type-equiv interface SubagentStartRequest { @@ -64,7 +64,7 @@ interface SubagentStopReasonMap { ## A live run: `SubagentRun` -The handle the consumer holds after a provider has established a ready child. The consumer awaits `result` and MUST `dispose` on every path to cancel remaining work and reach child quiescence. `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. +`SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. ```ts type-equiv interface SubagentRun { @@ -78,7 +78,7 @@ interface SubagentRun { ## The provider seam: `SubagentProvider` -One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance. +Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv interface SubagentProvider { @@ -89,11 +89,11 @@ interface SubagentProvider { } ``` -`SubagentProvider.start()` and `ctx.subagents.start()` are the publication boundary: their promises fulfill only with a ready run. The service attaches result observation, emits `subagent/start`, and returns the same holder-owned run; a rejected start has already cleaned provider-owned partial resources and emits neither lifecycle event. For an in-process provider, a start listener can resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path and reports `error` on infrastructure rejection. Both lifecycle events are observe-only emits with per-listener exception containment. +`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed -The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as an ordinary `Agent` in the same application. The provider creates it directly through `parent.ctx`, passes the required signal into the core creation transaction, and delegates quiescent disposal to the returned `AgentHandle`. Provider removal prevents new starts but does not revoke an accepted run. The child receives a flat new scope rather than inheriting the parent's registrations. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: +The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary: - **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child. - **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index d7e0464f01..68ae69f9c2 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -127,7 +127,7 @@ interface ToolExecution extends ToolExecutionInput { } ``` -`ToolExecutionToken` is a compile-time opaque fresh `Symbol` at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` materializes `arguments` as detached lossless JSON, assigns the token, and deep-freezes the accepted arguments. A non-JSON value is normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are readonly throughout the waterfalls, while an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers. +`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity. A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. @@ -184,7 +184,9 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn. +Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds from approval, and guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. + +Post-policy may replace content or block with corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured error results, so the call fails without ending the turn. ## The structured-output schema subset diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index f6bd6406ab..b047b5ce76 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with platform-native `fetch` at the repo's Node floor, mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. +`WebService` registers search and fetch providers and resolves them at execution time, returning disposers and structured selection errors. Providers use platform `fetch`; the local fetch backend owns URL, redirect, size, timeout, and decoding controls while the tool owns presentation. Private-network blocking is deferred, so do not enable `web_fetch` where it can reach sensitive internal targets. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index e2dd772eb0..371f7967de 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: ea5f2e5d08acbaf1dfce4661530218dbf1a051b9 -development.zh.md: 50877796f2ff47ad46cc67b35f3cd7b5704c8315 +development.md: 376df72c14b59b8ac8e42b31f19442040d6a47a7 +development.zh.md: b791797807bbf2a3ee8eb043bae7412df1f3446b diff --git a/docs/development.md b/docs/development.md index ea5f2e5d08..376df72c14 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,9 +67,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou ## CI gates -The keyless GitHub workflow has eight jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and three compatibility jobs run `pnpm run check:node-compat` on Node 22.19, 24, and 26. The compatibility command runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke on every runtime, so the matrix proves that the source graph typechecks and that a real unbuilt Worker loader path executes; the other lane schedulers fan out independent gates from `package.json`: constraints, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test. - -`pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=14`. +The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. ## Daily commands diff --git a/docs/development.zh.md b/docs/development.zh.md index 50877796f2..b791797807 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -67,9 +67,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v ## CI 门禁 -keyless GitHub 工作流有八个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,三个兼容性 job 在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。兼容性命令会在每个运行时上运行 TypeScript 类型检查和 keyless 的 workflow-workerthread 源码启动冒烟测试,因此该矩阵既证明源码图能通过类型检查,也会实际执行一条未构建的 Worker loader 路径;其他 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。 - -`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。 +keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 ## 日常命令 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index fa38b7a524..c27e557a37 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,47 +7,47 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../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:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../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), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../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:588`](../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:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:127`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:190`](../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:152`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:163`](../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), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:143`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../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:242`](../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:60`](../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:69`](../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:51`](../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:39`](../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:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:44`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:75`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8e6f0f7a54..325e7cc292 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:84`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly 'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome } ``` -Source: [`packages/ui/user-approval/src/index.ts:95`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:107`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) ### `bash/*` @@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) ### `hook/*` @@ -165,7 +165,7 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) ### `request/*` @@ -177,7 +177,7 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) #### `request/header-delta` — log-only @@ -187,7 +187,7 @@ Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) ### `steering/*` @@ -201,7 +201,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) ### `step/*` @@ -213,7 +213,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -223,7 +223,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) ### `todo/*` @@ -237,7 +237,7 @@ The agent's whole todo list, carried as a full snapshot and replaced wholesale o Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) ### `tool/*` @@ -251,7 +251,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -275,7 +275,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) ### `turn/*` @@ -289,7 +289,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -301,7 +301,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `user/*` @@ -315,4 +315,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) diff --git a/docs/postmortem/0001-acp-default-export-drops-inject.md b/docs/postmortem/0001-acp-default-export-drops-inject.md index 04f3910f1c..e024f4d698 100644 --- a/docs/postmortem/0001-acp-default-export-drops-inject.md +++ b/docs/postmortem/0001-acp-default-export-drops-inject.md @@ -4,7 +4,7 @@ Status: resolved (fix in PR #41 `feat/acp-2-bridge`) ## Executive summary -One stray line — `export default apply` at the bottom of the ACP plugin — made the ACP server crash the moment any editor connected, because the cordis Loader unwraps a default export and threw away the plugin's `inject` declaration along with it. A second, independent bug (an optional service read that fails through Cordis's traceable-shadow proxy) crashed `session/load` for a different reason. Both shipped green: 178 unit tests at 100% line coverage never caught either, because every test mounted the plugin by hand instead of through the real loader, and the only test that drove the failing requests was skipped in CI. The fixes are one-line each; the durable lesson is that **line coverage proved the code ran, not that the feature worked the way it ships** — so we added a no-key end-to-end test that boots the real example through the real loader, plus packages/AGENTS.md rules on plugin export shape and optional-service access. +Two integration mistakes broke ACP despite full unit coverage: a default export caused the Loader to discard `inject`, and a traced optional-service lookup failed across a shadow boundary. Hand-mounted tests bypassed both paths. The fixes added keyless real-Loader coverage and package rules for plugin exports and optional-service access. ## Summary @@ -82,7 +82,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob `ctx.reflect.get(name, false)` is a direct lookup in the global service store keyed by the isolate symbol — it ignores fiber topology entirely and finds the service. So from a top-level test the read works; from inside a real plugin fiber, reached via a shadow, it throws. The bridge is exactly the latter. -**Fix:** read the optional service through the same global store the bypass uses, but via the public `ctx.get(name)` — `this.ctx.get('sessionPersistence')` instead of `this.ctx.sessionPersistence`. `ctx.get(name)` is a direct lookup in the global service store keyed by the isolate symbol; it ignores fiber topology, so it resolves the backend regardless of which fiber or shadow the call arrives through. It is strict by default (an inactive/absent backend reads as `undefined`, which the existing guard rejects) — preferable to the `, false` overload, which would additionally skip the active-state check and could hand back a backend mid-teardown. The other reads in the resume path (`this.ctx.sessions`, `this.ctx.agents`) are fine — those *are* in `AgentLoop`'s `static inject`, so they sit in its fiber store and the ancestor walk finds them immediately. +**Fix:** read the optional service with `ctx.get('sessionPersistence')`, which uses the global isolate-keyed store while preserving active-state checks. Direct property reads remain appropriate for services in the plugin's declared injection set. ## Why every test missed it (the real failure) diff --git a/docs/rfc/README.md b/docs/rfc/README.md index fcb077e0a5..a6fe4dbfa2 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo ## Classification -Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and [INDEX.md](INDEX.md) is **generated** from the tree in full (`pnpm run gen-rfc-index` rewrites it from each RFC's path, H1 title, and filename date; the gate fails when it is stale, and rejects an index-shaped row in this file). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the index is generated while this prose stays curated. +Each RFC belongs to one path-encoded class from the closed set in `scripts/rfc-index.ts`; the classification gate rejects other folders. [INDEX.md](INDEX.md) is generated from paths, titles, and filename dates, and its freshness is gated. Adding a class requires updating the canonical set and this section. See the [classification](implemented/process/2026-06-20-rfc-classification.md) and [index-generation](implemented/process/2026-07-04-generate-rfc-index-tables.md) RFCs. | Class | What it covers | |---|---| diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 42efb34774..c137ce5cc6 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -19,6 +19,8 @@ In-session context injection (`context/message`, `steering/message`) renders as ## Consequences -- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). +- Reasoning has a core home without provider-specific shapes. +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). +- Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFCs. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index 7f30240ffd..b768e7d271 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -22,4 +22,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -Double the adapter maintenance and double the key-gated e2e surface (both adapters cover V4 Flash and Pro across representative thinking/effort modes). Bought: a continuously-verified neutrality guarantee for the most leak-prone abstraction in the codebase, and a worked second example for adapter authors. The two share the core Config shape (`apiKey`/`baseURL`/`models`) so a deployment swaps mostly one line, but the reasoning knob differs — `dsh-llm-deepseek` takes `thinking`/`reasoningEffort`, `dsh-llm-pi-ai` takes a single `reasoning` level — so a swap translates that field. If the maintenance cost ever outweighs the verification value (e.g. once conformance tests from [architectural conformance](../../proposed/process/2026-06-11-architectural-conformance.md) cover the contract mechanically), retiring the twin to a single adapter + the conformance kit would be a new RFC superseding this one. +The twin doubles adapter and key-gated e2e maintenance in exchange for continuous seam-neutrality validation and a second implementation example. Their core config shapes align, although reasoning controls differ. A future conformance suite could justify retiring one adapter through a superseding RFC. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 03d8a30b37..4cfa290181 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -20,7 +20,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** `load` preserves the contiguous, parseable events of an interrupted final turn and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. diff --git a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md index 10a6209f70..e55cd0853e 100644 --- a/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md +++ b/docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md @@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload. -The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log. +Failures detected during a turn are logged before `turn/end`. A later flush failure has no valid in-turn position, so it is reported through `agent/error` and logging rather than appended as a session event. This preserves a balanced replay log; durable operational diagnostics require a separate telemetry channel. diff --git a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md index 0ecf3e0e78..71efeff826 100644 --- a/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md @@ -34,7 +34,7 @@ The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-f Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer. -Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.) +Read-before-write/edit and observed state belong to `dsh-fs-policy`, not `ctx.fs`. Through the `fs/*` event gate, the policy records versions per opaque actor and supplies optional mutation expectations; the provider enforces freshness atomically. `dsh-tool-fs` emits the events without depending on the policy. See the [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs. ## Package topology diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index 97cfa7dbf4..f2c26acb37 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -12,30 +12,21 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow ### 1. Queue-aware `Agent.cancel(reason?)` -A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt. +`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker. ### 2. `AgentHandle` async disposer -`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw). +`ctx.agents.create`/`resume` and `AgentFactory` return `AgentHandle = { agent, dispose() }`. Disposal is a consumer capability; an observer holding only `Agent` cannot tear it down. The caller fiber and factory provider also own the instance, and every path shares one memoized teardown: stop the loop, await quiescence and flushes, detach the agent and session, then unwind its scope. IDs become reusable when their registry entries detach. Config-created agents belong to the loop fiber; ACP stores and disposes each session handle. -**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown. +Teardown order is load-bearing for durability. The session lifecycle and loop share one composite Cordis effect so LIFO disposal stops the loop and awaits `agent.done` before detaching the session. Sibling effects would dispose concurrently and could remove append hooks before the closing flush. Disposal notifications are contained so they cannot interrupt the chain. ### 3. Bash owner token in the seam Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped. -## Verification - -These invariants hold and are pinned by tests: - -- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown. -- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn. -- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor). -- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber. - ## Session owner tokens are unique among live agents -The bash owner-token comparison relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. +The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 4c8b5a81e3..305c092303 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The `Session` event log is the single source of truth ([event-sourced sessions](2026-06-11-event-sourced-sessions.md)), but the only view over it was `deriveMessages()` — a linear scan that filtered and transformed raw events into `Message[]`. This creates problems for session-history-manipulating plugins (compaction, tool-call result pruning, etc.). Without a central mechanism, each plugin would need to wrap `agent/request` to rewrite the message list — a pattern that suffers from listener-ordering fragility, provides no durable record of what was changed, and forces repeated changes to the core `deriveMessages()` whenever a new manipulation is added. A central hub in the `session` package, with a provenance-recording mechanism and enough flexibility for future plugins to manipulate session history through a stable API, lays a solid foundation for plugin development. +The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`. ## Decision @@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows). -Because the surface is the SOLE derivation path, a surface-eligible event that carries no `surfaceOp` marker is invisible to `deriveMessages()` — it would land in the log yet silently drop from history on resume/fork. `append`'s typed overload makes the marker mandatory for `SurfaceEventType` events at compile time, but only when the type argument is a SPECIFIC literal; when it widens to the `SessionEventType` union (a caller iterating raw events, e.g. `for (const e of log) append(e.type, e.data)`) the conditional rest collapses to optional and the compiler stops enforcing it. The marker requirement is therefore ALSO checked at runtime in two places: `append` itself throws on a marker-less surface-eligible event (covering the union-widening loophole), and the `Session` seed constructor re-checks the same invariant (alongside its seq-contiguity and JSON-serializability checks) so a seed/load/fork — which arrives as raw `SessionEvent[]`, bypassing `append` — is REJECTED rather than constructing a session that resumes with missing history. (No backward-compat path for surface-less logs: per the pre-release stance there is no persisted user data to preserve, so such a log is rejected, not upgraded.) +Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 5923ff434f..b1773b480d 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -39,4 +39,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Consequences -The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (six methods, no inheritance). The hook surface was deliberately held to the minimum: the create-collision probe is NOT a separate hook — it folds into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements a handful of small primitives instead of copying the entire `session/event` → buffer → flush machinery. +The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 8171a05d30..b475dd3851 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -6,11 +6,11 @@ Status: implemented The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. -**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. +**Gap 1 — unbranded IDs in the bash seam.** `BashTask.id` and every executor/tool boundary used bare `string`, even though the generated value has the same `name-N` shape as default session ids. The model also returns this value through `task_id`, so confusing task and session ids was both type-correct and reachable. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — erosion of existing brands.** `CallId`, `SessionId`, and `AgentId` became bare strings in registry maps, public lookup parameters, ACP session tracking, and the persistence coordinator. Dropping a brand at a lookup boundary defeats its main protection. ## Decision @@ -44,7 +44,7 @@ export function OwnerToken(id: string): OwnerToken { ### Why not typing `owner` as `SessionId`? -The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling. +The executor treats ownership as opaque and must not depend on the session model. A distinct `OwnerToken` preserves that boundary while preventing raw strings or task ids from being passed as owners. `dsh-tool-bash`, which owns the access policy, performs the single conversion from `SessionId`. ## Out of scope / possible extensions @@ -56,10 +56,6 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. - **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own RFC, not bundled into this type-only pass. -## Verification - -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. - ## Consequences - **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 0d8d6c02b4..902dd9c6bf 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -6,14 +6,14 @@ Status: implemented An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes. -The deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle. +The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through `session/new`; stdio requires a console logger and a pre-created `main`. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code. ## Decision Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root). -- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Service map): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension. -- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. +- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle. +- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact. - **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. @@ -36,13 +36,6 @@ Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger i The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stayed copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. -## Verification - -- Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone. -- `demo:echo` / `demo:repl` / `demo:acp` run via the app-package `bin`s. -- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). -- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record. - ## Consequences - **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight. diff --git a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index df4914f105..102a29d613 100644 --- a/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -12,7 +12,7 @@ The immediate prompt came from OpenRouter's [App Attribution](https://openrouter - **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard. - **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers." -- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them. +- **Coding agents identify the product and version in `User-Agent`.** Public implementations vary in environment detail and provider-specific side headers, but product identity is the common contract; there is no universal exact format. - **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request." - **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header. - **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally. diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 660b9821ff..40a95a7100 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -22,7 +22,7 @@ Web access is a first-class capability seam following [the capability-seam RFC]( Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. -Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. +Search and fetch are separate tools but one web-access seam. `ctx.web` owns provider selection, abort/error vocabulary, and deployment configuration for both parallel registries. Their request schemas and provider logic remain separate; the shared service is the product boundary for reaching the web. `dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: @@ -66,7 +66,7 @@ flowchart LR `@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with platform-native `fetch` at the repo's Node floor, mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. +Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -239,7 +239,7 @@ type WebFetchBody = `WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair. -`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim). +`WebFetchBody` is a closed discriminated union because body kinds require coordinated changes to the seam, provider, and tool rather than independent plugin extension. Exhaustive switches make a new kind fail compilation at every renderer until handled. Separate object arms leave room for kind-specific fields. The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index c890acb5c0..41f18f3a22 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who provider dsh-fs-local local implementation of ctx.fs ``` -The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance. +The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance. `dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`. @@ -68,9 +68,7 @@ The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down). -**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot. - -**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider. +**The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall. The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure. @@ -124,7 +122,7 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment. -**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story. +**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract. ## Policy plugin contract (`dsh-fs-policy`) @@ -152,10 +150,6 @@ Both mutations are still atomic (the backend's per-target lock is unconditional) This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change. -## Verification - -The decoupling and its semantics are pinned by tests: a bare-provider config (no `dsh-fs-policy`) boots the `dsh-tool-fs` root plugin and `read`/`write` (create and overwrite)/`edit` work against the real `dsh-fs-local` — an unread edit and an unread overwrite both succeed, proving the tool carries no `fileContext` dependency, while the same operations with `dsh-fs-policy` present are rejected `FS_NOT_OBSERVED` / gated `createIfAbsent`. A second `fs/edit-intent` listener registered after `dsh-fs-policy` is asserted NOT reached (first-wins short-circuit). A stale-read edit reports `FS_STALE_VERSION` through provider CAS, with `dsh-fs-policy` performing no `stat`; the tool's `stat` budget (read = 1, write = 0, edit = 0, on both paths) is asserted directly. Model-facing schemas stayed byte-for-byte unchanged, so snapshot transcript goldens are unaffected. - ## Alternatives considered - **Keep `ctx.fileContext` as an in-path method service** — the shape [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) first landed; rejected because the tool could not run without the policy layer, making policy load-bearing for basic operation instead of an opt-in tightening. diff --git a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 4895d08c49..de6428fb64 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -14,18 +14,18 @@ Add `stdin?: string` and `env?: Record` to **both** `BashExecReq Three deliberate choices: -1. **The model-facing `bash` tool simply does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only; a model that includes `env`/`stdin` keys in its tool-call arguments simply has them ignored. A regression guard (`tool-bash` "does not forward env/stdin" tests) drives the real tool with those extra args and asserts the recorded request carries neither field — its purpose is to catch a future refactor that blindly spreads `...args` into the request and silently starts forwarding model input into the post-scrub `env` merge, NOT to defend a trust boundary. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it). +1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. 2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. -`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`. +`dsh-bash-local` creates a stdin pipe only when bytes are supplied; otherwise fd 0 remains `/dev/null`, preserving prior behavior. It writes the bytes and closes the pipe. `EPIPE` from a child that exits without reading is ignored because command exit and output determine the result. ## Alternatives considered -An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a speculative surface with no consumer. If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then. +**Configurable ambient-secret scrub.** Rejected as speculative. Trusted callers can explicitly provide required values after the scrub without weakening the default ambient protection. ## Consequences -A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged (the credential scrub, not these fields, is what bounds it), and the `bash` tool's request-building stays the single place that decides which fields a model call carries — guarded by a test that fails if a refactor starts forwarding model input. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs. +Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../core-data-structures/bash.md). diff --git a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md index d0656c9327..fad851265d 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md +++ b/docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md @@ -6,7 +6,7 @@ Status: implemented The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces. -The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide. +Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical. ## Decision diff --git a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 45d5a77e46..218cb29297 100644 --- a/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -22,7 +22,7 @@ Add a **persisted, tool-private presentation channel** so a tool's `execute` can type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } ``` -`meta` is an opaque payload the core never interprets — typed `unknown` at every seam (the tool that produced it owns and narrows its shape). It MUST be JSON-serializable: the registry threads it onto the `tool/result` **session event**, and `Session.append` runtime-validates all event data with the existing `isJsonValue` predicate, so a non-serializable `meta` is rejected at the source. On replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. Typing `meta` as `unknown` (rather than a shared serializable-value type) keeps the tools core free of a dependency it would otherwise take just to name the type, and the runtime `isJsonValue` gate — not the static type — is what actually enforces serializability. +`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core. This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it. @@ -31,7 +31,7 @@ This is the general shape ("a tool attaches durable result presentation"), not a Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**: - `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam. -- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A contextual hunk is computed only when a before-version exists — edit always; write on overwrite; a create has no before, matching `claude-agent-acp`'s empty `structuredPatch` on create. But the completed `tool_call_update` is ALWAYS a `diff` card for a successful mutation: an ACP `tool_call_update.content` REPLACES the call's content, so rendering the model-facing result text would clobber the pending diff. So `write`'s result falls back to an args-derived whole-file diff (`oldText: null`) when it has no contextual hunk (a create, or an overwrite whose content is unchanged), and `edit` — which always changes content — always has a hunk. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and falls through to the generic error rendering (its message must show). +- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally. ### 3. The bridge renders a `diff` result card @@ -39,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac ## Alternatives considered -**Hand-rolling or vendoring the diff algorithm.** Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`). +**Hand-rolling or vendoring the diff algorithm.** Contextual hunks have established edge cases, so `dsh-tool-fs` uses the typed [`diff`](https://www.npmjs.com/package/diff) package and normalizes `structuredPatch` output in one module. The repository's vendoring policy applies to its framework source, not every leaf utility. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 0961830c1a..0f1437db84 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 @@ -24,21 +24,21 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map, then strict rendering rejects unknown, missing, malformed, or duplicate names. A lone unmatched `{{` remains prose, and substituted values are not rescanned. Section names are also unique. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### Persona as the order-0 section -`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. +`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)`. 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 -Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. +Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior. ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered @@ -54,13 +54,6 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship - Further variables (`date`, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here. - A config `cwd` for pre-created stdio agents (would let the stdio persona use `{{cwd}}` and partition persistence by real path) — deferred until the session-cwd story is revisited. -## Shipped invariants - -- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. -- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. -- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. -- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. - ## Consequences - Every fact in the assembled prompt now has exactly one owner, and the hand-maintained tool prose in leaf YAML is gone: loading or dropping a tool plugin no longer means editing any deployment's persona. 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 c03362d3b3..c890475ba3 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design. +The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded. The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing. @@ -24,18 +24,18 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro Each step rebuilds the prompt assembly, composes and freezes the session prefix once per loop instance, runs `agent/pre-step`, snapshots derived messages immediately before `step/start`, and folds call config from the logged header. `agent/request` may replace only the frozen config seed; model-visible content must enter through logged channels. The loop then records the owed header event, builds `GenerateOptions` from the 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. -**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward. +**`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 folds through the step's own `request/header*` event, or carries the prior fold when no new header is written. -**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step. +**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request from the log prefix and folded header, then compares messages and 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. ### The MiniCode shape: adopted, with the provenance arrow inverted -What survives from `LLMClient`: the conversation is maintained, not rebuilt — one projection per message, ever; requests advance append-only; resets happen only for a system-prompt/tool change, a config change, or compaction, each now a *logged* fact. What is deliberately inverted: MiniCode's client is the source of truth and its event stream derives from client appends (`on_event(MessageAdded)`), which suits an advisory event stream. Here the log is contractual — persistence, crash recovery, fork seeding, transcript rendering, and the snapshot harness all replay it — and it carries strictly more than a message list (turn/step boundaries, raw chunk streams, tool-call pairing, provenance, log-only records), so a message-list client cannot generate it. The arrow therefore points log → client: the conversation state IS the log plus two cached folds inside `Session` (messages, header), and the "client" the loop talks to is the session itself. What the inversion buys over the original: the reconstruction is *checkable* against an independent record on every request — MiniCode's client has nothing to check itself against. +Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and provenance. `Session` caches message and header folds derived from that log, making every request independently checkable. ## Alternatives considered - **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above. -- **A stateful transmission client mirroring the log** (a `PromptPrefix` class holding committed/open message zones with an append/editTail/reset vocabulary, the log pushed into it per event): behaviorally equivalent on the happy path, but it duplicates conversation state outside the session, needs transactional rollback around listener seams, keeps an unlogged content-shaping surface (`editTail`) whose divergence the invariant must specially allow, and still cannot answer "what header did the model see" from the log. Dissolving it into the session's own caches plus logged header events made every one of those problems unrepresentable instead of guarded. (PR #162 is the archaeology of this alternative, three designs deep.) +- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths. - **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records. - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 46e7ea4e71..155cee60f1 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -29,6 +29,6 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis ## Consequences - Consumers deriving state from a named provider react to `subagent/provider-added`/`-removed` instead of reading the registry at `apply` time; `dsh-tool-subagent` is the reference implementation. -- **The two emits carry asymmetric failure semantics, deliberately.** `provider-removed` fires inside the registration's disposer and is delivered with PER-LISTENER containment (the service's `emitLifecycle`, not raw `ctx.emit`, which halts dispatch on the first throw): a throwing subscriber is logged, never starves a later mirror into holding a stale tool, and never disrupts the backend fiber's teardown — dispose reaches quiescence. `provider-added` propagates: it fires at registration time, where a throwing listener unwinds the yielded rollback — the same fail-loud register-time semantics as the system-prompt registries. The run-time backstop bounds what a stale mirror could cost anyway: `start()` re-resolves the provider by name per run, so a tool that outlived its provider fails that call cleanly instead of dispatching into a dead backend. The [events catalog](../../../cordis-catalog/events.md) carries the exact signatures, and the [producer/consumer map](../../../event-producer-consumer.md) shows `dsh-subagent` emitting and `dsh-tool-subagent` consuming both events. +- **Addition fails loud; removal is contained per listener.** An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown. `start()` still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the [events catalog](../../../cordis-catalog/events.md). - **A window where the tool is absent.** Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's `tools/change` emit keeps prompt assembly current. - **Two waiting fibers sharing a `toolName` is an invalid config caught late.** If two loads of `dsh-tool-subagent` name different providers but the same `toolName`, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives. `TODO(subagent-dup-toolname)` in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop. diff --git a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md index 443f24597e..7aa987c60f 100644 --- a/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -12,8 +12,6 @@ Timeout handling was drifting apart across the tool-bearing capabilities, and th Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash kills an OS process group (work runs in a child process, outside this runtime, reachable only by signal), while web aborts an in-process `fetch` (undici tears down the socket). There is no single mechanism that can stop all of them. -The two reference agents surveyed converged on the same split. Codex models "what will end this exec early" as one value (`ExecExpiration`, an enum fusing timeout and a cancellation token) whose `wait_with_outcome()` returns `TimedOut | Cancelled`, while the actual `kill_process_group` lives outside it — and that abstraction is reused *only* across the exec family, with MCP, model-stream, and guardian each keeping their own bespoke `tokio::time::timeout`. Claude Code shares nothing: bash and ripgrep each own a private SIGTERM→SIGKILL kill and distinguish timeout from cancellation by throwing distinct error types, while file I/O has no timeout. Both confirm the boundary drawn here: the timing-and-classification half is worth sharing within a family of like-terminated operations; the termination half is not shareable and stays in each capability. - ## Decision `@deepseek-ai/dsh-timeout` lives under `packages/util/` (peer to `dsh-brand`) and owns the *timing and classification* half of timeout; the *termination* half — the hard kill — stays in each capability's implementation. It is a library of pure functions, **not** a cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. There is deliberately no central "timeout service" that would have to know how to stop every capability's work — that knowledge is exactly what a microkernel keeps out of shared layers, and what Codex's exec-only `ExecExpiration` scope demonstrates. @@ -76,7 +74,7 @@ The signal only *notifies*; termination is always the listener's job, and the li ### How each capability consumes it - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. -- **bash** — `resolve()` stays a pure request-to-spec step: it clamps with `clampTimeout(request.timeoutMs, config.timeoutMs, config.maxTimeoutMs, 'bash-local: request.timeoutMs')` and carries `request.signal` through unchanged. Foreground `run()` owns the timeout: `using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')`, then `runBash` receives only `d.signal`. `runBash` no longer owns any timer — it listens for abort and runs its existing SIGTERM→grace→SIGKILL process-group kill, and its `SpawnSpec`/`SpawnOutcome` no longer carry `timeoutMs`/`timedOut`/`aborted` (the executor classifies from the deadline signal instead). `run()` computes `timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined` and `aborted = d.signal.aborted && !timedOut`, so the public seam booleans (`BashRunResult.timedOut`/`aborted`) are mutually exclusive — the shared deadline reports the cause that first cut the command short, and the `code` scope keeps a nested outer deadline from being misread as bash's own timeout. Background `start()` creates no deadline and forwards only the upstream signal, so background tasks stay timeout-free; a task's killed-vs-completed status reads its own `spec.signal.aborted`. +- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. ## Consequences @@ -95,4 +93,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode **A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. -**Keep bash's two independent triggers (`killTimer` + `onAbort`) rather than fusing.** Rejected for the convergence goal: fusing into one `deadline` signal removes bash's bespoke timer and gives every capability one shape. The trade-off is that bash's `timedOut`/`aborted` booleans become first-abort classifications rather than independent facts that can both be true when timeout and user abort race before process close. That is acceptable because the result reports the cause that first cut the command short; the termination action stays the same uniform SIGTERM→grace→SIGKILL kill. Note the deliberate non-alignment with Codex: Codex forks its kill by outcome (timeout → immediate SIGKILL; cancel → SIGTERM + 50 ms grace → SIGKILL), whereas the fused signal drives one uniform `kill()` for both, matching Claude Code's unified bash kill. Splitting the kill by `timeoutOf` is possible later if a need appears; there is none now. +**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged. diff --git a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md index 362f5cb5e8..4f31c48623 100644 --- a/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md +++ b/docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md @@ -50,7 +50,7 @@ The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespac searchTimeoutMs: 30000 ``` -Keeping the tool name out of this plugin's config is deliberate: a budget keyed by a free-text tool name could be mistyped (`web_fech`) and then silently apply to nothing. Declaring `timeoutMs` on the tool makes that failure class structurally impossible — the enforcer reads `ctx.tools.get(exec.name)?.timeoutMs`, and `exec.name` is the tool being dispatched, so the lookup always resolves and there is no unknown-name path to warn or throw about. `timeoutMs` is validated positive-finite by `defineTool` at definition time. For a tool that declares a budget the listener arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')`, swaps the derived signal onto `exec` for the downstream dispatch, restores the caller's own signal afterward, and returns a structured `TOOL_TIMEOUT` result when `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches. A tool with no declared budget delegates unchanged. +Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal, restores the caller signal afterward, and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged. Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so the documented cordis idiom — mutate the shared object, then delegate — is the only mechanism that reaches dispatch. The plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees this plugin's (possibly already-aborted) deadline signal. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 063cc4738c..d97d21c05b 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -276,7 +276,7 @@ The service validates provider capabilities and request semantics before calling Spawn and fork share one in-process driver. It creates the child through `parent.ctx`, passes the required signal into the core creation transaction, and installs persona, tool restriction, and structured-output contributions during unpublished setup. -The provider awaits creation and returns only the published run. At the handoff, core creation detaches its creation-only abort listener; the provider immediately rechecks the signal before installing the live-run listener, so an abort in that narrow interval disposes the new handle instead of escaping cancellation. Parent teardown follows the child because the operation belongs to `parent.ctx`; provider unload blocks new starts but does not become a second revocation owner for accepted runs. The run disposer cancels the child and awaits the AgentHandle's ordered teardown. +The provider returns only a published run. At handoff, it rechecks cancellation between removing the creation listener and installing the live listener; an abort there disposes the new handle. Parent teardown reaches the child through `parent.ctx`. Provider unload blocks new starts but does not revoke accepted runs. Run disposal cancels the child and awaits ordered `AgentHandle` teardown. Spawn uses an empty session seed. Fork uses a validated completed-turn prefix. Conversation seeding changes history only and does not import scope, tools, services, or authority. diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index 293b1bbde6..d32fe71d4a 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -24,7 +24,7 @@ Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentRe Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. -The bridge advertises ACP config options instead of session modes. `sandbox-mode` exists only when the mounted bash executor reports sandbox capability, and `approval-policy` exists only when `ctx.approval` is composed. Each option is an independent select whose current value is the session event fold over the composition default. `session/set_config_option` validates against the owning domain vocabulary and writes through `setSandboxMode` or `setApprovalPolicy`. An open-turn switch appends immediately; an idle switch is overlaid in the response and anchored at the next turn start. Until that anchor it is memory-only and a crash reverts to the durable fold. ACP session modes are deliberately not modeled because one mode list cannot represent these orthogonal knobs and config options are the forward protocol surface. Runtime model selection remains outside this decision; `AcpConfig.model` is connection-wide. +The bridge exposes independent ACP config options for sandbox mode and approval policy only when their services exist. Changes validate against the owning vocabulary and enter the session fold immediately during a turn or at the next turn boundary while idle. Session modes are not used because they cannot represent orthogonal controls; model selection remains connection-wide. The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 09b61b62ed..5325a0344b 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -24,33 +24,33 @@ Three decisions, each elaborated in its own section below: `ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list = the registry's contribution before cooperative assembly.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the final presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed there, and cannot be named by `ctx.tools.restrict()`. The mode governs this provider's input to assembly; other direct `systemPrompt.tools()` providers own their schemas, and the trusted assembly waterfall owns the returned wire list. +**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. -**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text. +**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section renders TypeScript declarations for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for stable output. **Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. -**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise; bash(args: …): Promise; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so. +**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. ### The run_code tool and the dispatch bridge Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. -3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results. +3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. -**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode. +**Sub-call `additionalContext` is omitted.** Injecting it during `run_code` would break parent call/result adjacency, while one program can produce many contexts. Supporting it requires a plural channel or loop-level sub-dispatch buffer. -**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe. +**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata. **Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. ### Observability: `tool/code-dispatch` -Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows. +Each sub-dispatch appends a log-only `tool/code-dispatch` event containing parent and child call ids, tool identity, normalized arguments, and result summary. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. ### The code-runtime seam @@ -63,7 +63,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout. - Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's optional-backend idiom: Cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk. The seam has concrete divergence on both axes: the worker-thread substrate can be replaced by a container or microVM implementation, and the TypeScript language contract can be paired with a language-specific SDK and runtime. `dsh-tools` consumes only the interface and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template. +Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. ### The worker-thread runtime @@ -73,7 +73,7 @@ Per explicit-over-implicit at seams, the request spells out everything the runti 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. 3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented). 4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. -5. **Enforce caps — two independent budgets, because the peer is hostile.** The compute budget (`computeMs`) meters the worker's *measured busy time* via `worker.performance.eventLoopUtilization()` polling — not host-side "is an RPC pending" bookkeeping, which a program defeats by firing an un-awaited call at a slow tool and then spinning hot while the host thinks it is waiting. Measured busy time cannot be gamed: a hot loop accrues it whether or not a dispatch is in flight, and a program genuinely awaiting a slow tool accrues none, so a long-running `bash` sub-call still does not kill an innocent run. The wall ceiling (`maxWallMs`) never pauses for anything and backstops what busy-time cannot see (a program awaiting a promise nobody will resolve). Budget expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap); the failure reports which budget fired. Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config, truncation marked in-band. All caps are validated config fields with defaults (`computeMs: 60_000`, `maxWallMs: 600_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`. +5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. Expiry, cancellation, and completion terminate the worker. Heap exits and truncation are reported explicitly; compute, wall, heap, log, and return-value caps are validated configuration. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../defensive-patterns.md). ### Trust posture @@ -82,27 +82,11 @@ The worker runtime provides containment, not a security boundary: model code can ### What the model sees -The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program is the body of an async TypeScript function (erasable syntax only — no `enum`/namespaces; type annotations are advisory); call tools as `await tools.name(args)` (quoted access for exotic names); a failed tool call **rejects** with an `Error` carrying the tool's error text — catch it to handle and continue; calls run **sequentially** even under `Promise.all`; emit results via `return` and/or `console.log`, and only that curated output returns to the context — intermediate tool results never do. That last line is the payoff the whole design serves: output-side context cost becomes the model's own editorial decision. On the input side the `.d.ts` is not free — for a large tool surface it can rival the native JSON schemas it replaces (and `'both'` pays for the two side by side) — but it is prefix-stable, so provider prefix caching amortizes it; the win is workload-dependent and the RFC claims no more. +The SDK instructs the model to write an async erasable-TypeScript body, call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. ## Consequences -The design consists of the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` presentation and dispatch integration. - -Shipped surface: - -- **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog. -- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, lazy `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog). -- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution. -- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the registry-owned presentation transport, while assembly listeners may rewrite the final model-visible surface and own its protocol integrity; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on. - -## Testing - -What the suites pin, per tier: - -- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md). -- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, scoped shadowing, authoritative assembly transformation, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety. -- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output. -- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed. +Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, and the bridge does not propagate per-call `additionalContext` until those contracts are designed for Code Mode. ## Alternatives considered @@ -132,7 +116,7 @@ What the suites pin, per tier: **Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`. -**Structured-clone limits at the binding boundary.** The seam's clone boundary admits values JSON does not (`Date`, `Map`, `BigInt`), and the session log accepts only JSON — left unhandled, a sub-call could execute and then fail at `tool/code-dispatch` append time. Closed by the bridge's JSON-normalization step (§ the dispatch bridge): what does not survive the round-trip rejects that binding call before dispatch, so every executed sub-call is loggable by construction. The seam itself keeps the wider structured-clone contract (it is about the port, and stated so a future binding producer cannot discover it in production); consumers with stricter payload needs enforce them at their own boundary, as the bridge does. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions. +**Structured-clone values can exceed JSON.** Tool bindings therefore JSON-normalize arguments before dispatch, ensuring every executed call can be logged. The lower-level runtime keeps its wider port contract, while stricter consumers validate at their boundary. Non-text sub-results become placeholders. **Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs. diff --git a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md index 5eb38f1f99..3b8cd810a8 100644 --- a/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md @@ -6,7 +6,7 @@ Status: implemented The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. -That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.) +Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card. ## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` 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 264633f2a1..194baa1053 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 @@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam -Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed. +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. 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`): @@ -46,9 +46,7 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction request = waterfall agent/request ⟵ pure request transform (hooks, model switch) ``` -This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface. - -This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive. +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. ### Retention is turn-agnostic; tool-pairing balance is the only structural guard @@ -62,7 +60,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed* ### Head-anchoring: one auto checkpoint, always at the head -`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.) +Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints. ### Approximate convergence invariant @@ -85,7 +83,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab ### Checkpoint framing + incremental merge (backend-private) -The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all. +The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary. ### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy @@ -118,10 +116,3 @@ Two failure paths, both documented: - **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). - -## Testing - -- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path. -- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request. -- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net. -- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 652eac5522..ede9eb155b 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -37,7 +37,7 @@ A new package group `packages/subagent/`: ### The primitive: async `start → SubagentRun` -A provider exposes `start(request) → Promise`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event. +A provider exposes `start(request) → Promise`. Fulfillment publishes a ready child and transfers its run handle to the caller. One signal covers cancellation before and after readiness; `dispose()` cancels remaining work and awaits quiescence. A rejected start cleans partial resources and emits no lifecycle event. `start` is transport-neutral; `spawn` names only the fresh in-process backend. ### Two kinds of optional capability, discovered two ways @@ -46,7 +46,7 @@ A provider exposes `start(request) → Promise`. Promise fulfillmen ### Fork vs. fresh are separate backends, not a flag -Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn that the [invariants](../../../../packages/support/invariants/src/index.ts) trace replay rejects. +Fresh and forked children are separate providers, not a request flag. `dsh-subagent-spawn` starts an isolated child; `dsh-subagent-fork` seeds a balanced prefix containing only completed parent turns. The in-flight turn is excluded because its subagent call has no result yet and cannot form valid replay history. ### Child isolation and the parent log @@ -54,20 +54,15 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p ### Synchronous collect (first cut) -The `dsh-tool-subagent` consumer passes its execution signal into the start request, awaits the ready run's `result`, and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. A `try/finally` always `dispose()`s the run, so no success, failure, or cancellation path leaks an idle child/session. A non-`completed` stop reason maps to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but intentionally unused in this consumer. +`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run in `finally`. Non-completed outcomes become error results rather than successful partial output. This foreground consumer does not use the run's optional steering method. ### Provider selection is config, not model-facing `dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. -## Testing - -The seam is tested through the real cordis Loader / export path, not a hand-built `ctx.plugin` mount (which bypasses `unwrapExports` and cannot catch a broken export shape — [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)); the registry pins HMR-safety, duplicate-name rejection, and start-time capability rejection; the nested-agent snapshot scenarios replay keyless in the default gate ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); in-process backends carry real-loop unit tests plus a with-key e2e. - ## Consequences - **Recursion.** Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global `toolFilter`; ACP advertises both capabilities off and rejects such a request. The [subagent composition-controls RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) owns their exact semantics and security limits. - **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. - **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. -- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`. It was built single-session: a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and a harness that harvested a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needed per-session-keyed replay plus harvest-all-logs and plural-session-id plumbing — self-contained infrastructure orthogonal to the backends, scheduled as a dedicated stacked follow-up rather than folded into the in-process-backends PR. That follow-up has **landed**: see [Per-session snapshot replay for nested agents](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). Replay now keys each call by its calling session (`GenerateOptions.sessionId`) and binds live sessions to recorded scripts by first-call order; the harness harvests every log; and two nested scenarios (`subagent-spawn`, `subagent-multi`) replay keyless in the default gate. In-process subagents remain covered by real-loop unit tests and a with-key e2e in addition to the snapshot tier. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 518f9dedef..40dbff8d40 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -30,19 +30,11 @@ ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `ma The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. -## Testing - -Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time: - -- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Coverage includes the prompt round-trip and output accumulation; every StopReason mapping; cancellation through the required request signal and through disposal; already-aborted and cancel-races-ahead-of-newSession starts; a torn pipe after cancellation settling `aborted`; permission auto-answer under both policies; non-message updates; nonexistent-command startup failure with process reaping; provider HMR; and the namespace export shape. -- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. -- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. - ## Alternatives considered -### Why not the 0.28.x SDK bump? +### Why stay on SDK 0.25.1? -The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. +The backend needs only `ClientSideConnection`, `ndJsonStream`, `PROTOCOL_VERSION`, and the client protocol types, all supported in 0.25.1. The 0.28 fluent API would require migrating both client and server connection classes across the ACP layer without improving this backend, so that upgrade remains a separate change. ### Why not a persistent child process? 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 90bd274eb3..126c69f1c0 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 @@ -32,7 +32,7 @@ claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew i ### Single owner — no swarm machinery (YAGNI) -The list belongs to the ONE agent session that called the tool (`exec.agent.session`); a non-agent caller is rejected. There is deliberately no shared/multi-owner scope, no capability seam (interface/impl/consumer), no scope resolver, and no delta protocol. The harness does have subagents, and a shared cross-agent list is conceivable — but building that now means designing for a form the product does not yet have. The whole-list-replace + single-owner shape is what claude-code V1, opencode, and codex all ship; if a shared list is ever needed, the on-log representation would change to per-item deltas (so concurrent writers can't clobber each other) and a scope resolver would choose the target log. That is a future RFC, not speculative scaffolding today. +Each list belongs to the calling agent session, and non-agent calls are rejected. There is no shared scope, resolver, or delta protocol. Cross-agent lists would require per-item log deltas and explicit scope selection, so they remain a separate future design. ### Validation: the cheap middle diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 2afcce9914..0789204e78 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -49,7 +49,7 @@ The config is parsed ONCE at load; a read/parse failure logs and registers nothi ### Where hooks run, and where their config comes from -Two different cwds, kept distinct on purpose. The hooks **themselves** run in the agent's **session workspace**: for the agent-scoped points the bridge threads the session's `cwd` (`session/new.cwd`, on the session header) to `runHook` as the process working directory, so a hook's `pwd` / relative-file read / marker write operates in the user's project tree, not the server's launch directory. The **config path**, by contrast, is **process-level**: `configPath` is resolved and parsed once at load against the process launch cwd, so a single `hooks.json` applies to the whole process — there is no per-session config discovery that reads a project-local `hooks.json` from each `session/new.cwd` (`TODO(per-session-hook-config)`). This is an honest limitation of the current cut: the example `cordis.yml` documents that its `./hooks.json` is process-level, not per-project. +Hooks run in the agent's session workspace, so relative paths target the user's project. `configPath` is resolved once against the process launch cwd and applies to every session. Per-session project-local discovery remains deferred under `TODO(per-session-hook-config)`. ## Deferred (faithful-but-degraded) @@ -57,7 +57,7 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th - **Stop loop-guard** (`TODO(stop-loop-guard)`). CC/Codex break an infinite force-continue with `stop_hook_active` (true once a Stop hook fired this run) plus a max-consecutive cap; both are deferred. `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands. - **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile. - **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`). -- **Session-start / subagent-start context is best-effort, not gated (`TODO(session-start-gating)`).** `agent/session-start` is a synchronous emit and the bridge runs its hook on a detached `.then`, so the injected `additionalContext` is not guaranteed to land before the first turn reaches the model — a slow hook can miss the first request (the context then arrives as a later injection). `subagent/start` is emitted only after child publication, so the bridge can capture the live in-process child synchronously, but the result driver may queue the prompt as that same readiness boundary resolves and a short-lived child can finish before the detached hook injects. Making startup context a gated/awaited primitive is a loop-level change deferred to the interception seams; the contract is "injected as soon as the hook resolves", not "before the first request". The bridge tests do NOT wait on the injection where they assert the guaranteed-timing behavior, so they document the real (best-effort) timing rather than masking it. +- **Session-start / subagent-start context is best-effort (`TODO(session-start-gating)`).** Both hooks run detached from startup, so their context is injected when ready but may miss the first request or a short-lived child. Guaranteeing first-request delivery requires an awaited startup seam. ## Alternatives considered @@ -65,4 +65,4 @@ Two different cwds, kept distinct on purpose. The hooks **themselves** run in th ## Consequences -The bridges are thin and readable standalone: the correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in the shared `dsh-hook-protocol`, so each bridge is just config-parse + payload-build + outcome-map. Each is covered at per-file 100% — config-parse branches as unit tests, and the seam mappings end-to-end through the REAL loop + REAL `dsh-bash-local` + REAL shell scripts from a temp `hooks.json` (a scripted mock MODEL is the only stand-in), plus a real-Loader export-shape guard so a stray default export can't silently drop `inject`. Because the seams already carry typed Decisions, a future native plugin needs none of this bridge machinery — it returns a Decision directly. +Matcher semantics, exit-code handling, and merge precedence live in `dsh-hook-protocol`; each bridge only parses config, builds dialect payloads, and maps outcomes. Native plugins bypass the wire protocol and return typed decisions directly. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index fa83dbd779..a242bde2fd 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -23,8 +23,8 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Alternatives considered -**One parameterized engine.** A single engine parameterized by a full `dialect` descriptor was considered and rejected. The payload construction and decision mapping are where the dialects genuinely diverge (different field names, different supported outputs, CC's env/substitution); folding those into a data-driven descriptor would make the *bridge* logic indirect — a reader of `dsh-hooks-claude` would have to chase a descriptor to see what payload it sends. Keeping the truly-identical primitives shared (matcher, codec, runner, merge, events) and letting each bridge write its own straightforward payload+mapping keeps each bridge readable standalone, at the cost of a little duplication in the payload shape. The primitives are the part where duplication would actually be dangerous (a divergent matcher or exit-code rule is a correctness bug); the payload is the part where explicitness beats sharing. +**One parameterized engine.** Rejected because payload construction and decision mapping genuinely differ by dialect. Matchers, codecs, execution, merge rules, and events remain shared; each bridge keeps its payload and mapping explicit so its wire behavior is readable in place. ## Consequences -The two bridge plugins become thin: parse the config file, pick a matcher mode, build the per-event payload+env, call `runHook` + `mergeHookOutputs`, map the outcome to a Decision, and append `hook/*`. The protocol's correctness-critical halves (matcher semantics, exit-code contract, merge precedence) live in one tested place — `hook-protocol` ships with heavy unit tests (matcher per-mode, codec per exit-code/field, runner plumbing with a stub executor, merge precedence, the `hook/*` helpers) at per-file 100%. Input rewrite (`updatedInput`) is parsed but not honored (the deferred [pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs+warns on it. The package is a library, so it has no `cordis.yml` load path of its own — its real-load-path coverage comes through the bridge plugins that consume it. +Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. The library's load path is exercised through its bridge consumers. diff --git a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md index cb653c2519..ea371ad55a 100644 --- a/docs/rfc/implemented/feature/2026-06-30-interception-seams.md +++ b/docs/rfc/implemented/feature/2026-06-30-interception-seams.md @@ -20,7 +20,7 @@ The canonical surface separates transformable policy, around-dispatch control, a ### The tool pipeline gives each phase one kind of authority -Every call follows one ordered pipeline: `tools/pre-execute` → monotonic guards → `tools/execute` → core dispatch → `tools/post-execute` → `tools/result`. The registry reads each caller-owned input field once, materializes `arguments` as detached lossless JSON in one recursive pass, and snapshots `ToolExecutionInput` into a pipeline execution with its own opaque token. Identity fields and deeply frozen arguments are immutable for the whole pipeline, and a nested call's `parent` contains only the enclosing execution's token rather than its live object. Optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove, and the complete object freezes before final observers run. This identity contract prevents a policy listener from silently changing what the log, UI, and tool body believe ran. +Every call follows `tools/pre-execute` → guards → `tools/execute` → dispatch → `tools/post-execute` → `tools/result`. The registry snapshots caller input, materializes and freezes arguments, and assigns an opaque token. Nested calls carry only the parent token. Identity remains immutable; only `signal` may change around dispatch. The log, UI, and tool body therefore agree on what ran. - **`tools/pre-execute`** is the extensible waterfall gate. Its `PreToolDecision` allows, denies, or asks. Deny skips `tools/execute` and core dispatch. Ask resolves through the optional approval seam: only `allowed-once` continues through guards and dispatch; rejection, cancellation, an unavailable channel, a missing approval service, or an agent-less call becomes a normalized denial. Every outcome still reaches post-policy and final observers. - **`ctx.tools.guard()`** installs synchronous scope-aware policy after the whole pre-execute waterfall. A guard may deny or abstain, never force-allow, so listener ordering cannot resurrect an operation that a final invariant forbids. @@ -34,7 +34,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Three load-bearing loop decisions -1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected`→`cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn. +1. **Open the turn before prompt policy.** A fully blocked batch becomes a zero-step `rejected` turn, preserving enclosure and giving ACP a durable terminal event. Every veto also records `prompt/blocked` with the original prompt and reason, so mixed batches retain blocked inputs. Allowed `additionalContext` is injected into the open turn. 2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended. @@ -42,7 +42,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li ### Pre-tool input rewrite is a separate consistency decision -`PreToolDecision` is allow/deny/ask only — **no `arguments` rewrite**. Output replacement is safe because `tool/result` is logged after execution from the final result. Input rewrite is different: `assistant/message` (model history) and `tool/call` (the audit record) are logged before `ToolRegistry.execute()`, while ACP and tool presentation read those arguments. The registry therefore seals the materialized arguments before `tools/pre-execute`; no listener or test shim can mutate them in place. An honest rewrite must update history, audit, presentation, and execution as one unit before that identity is created, which belongs to the separate [pre-tool input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) and its loop-side `TODO(pre-tool-input-rewrite)`. +`PreToolDecision` cannot rewrite arguments. History and the audit call are logged before execution, and ACP presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md). ### Boundaries diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index bf366afc02..8d8813563b 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -24,7 +24,7 @@ class SessionStore extends Service { `boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy. -The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). A boundary that is not an existing event seq, is not a safe integer, or does not point at `turn/end` is rejected with a typed `SessionForkError` code. Broader session-log sanity remains in the existing invariant/repair layers: `dsh-invariants` checks turn enclosure and richer event ordering in dev, while persistence repair handles the valid crash-tail case of a final interrupted turn. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`). +An empty prefix is forkable; any non-empty boundary must be a safe existing sequence at `turn/end`, regardless of reason. Typed errors distinguish missing sources, stale objects, duplicate child ids, and invalid boundaries. Broader log validation and crash repair remain with their existing owners. ## Alternatives considered diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 9fef336d9c..273c1bdd37 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int ### The script contract (Claude Code-compatible) -A workflow call is two parts: a `meta` JSON parameter (the identity block — `name`, `description`, optional `whenToUse`/`phases`; the field vocabulary matches Claude Code's meta block) and a `script` — a plain-JS body with top-level `await`, ending in `return `. Meta is DATA, never code: the engine shape-validates it and evaluates no script text to obtain it (a body still opening with a CC-style `export const meta` statement is rejected with a pointed message). The body sees exactly: `agent(prompt, {label, phase, schema, model})`, `parallel(thunks)`, `pipeline(items, ...stages)` (NO cross-stage barrier; `(prev, item, index)` callbacks), `phase(title)`, `log(message)`, and `args`. CC semantics are preserved where they matter to script authors: a failed child resolves `null` (scripts `.filter(Boolean)`); an ordinary stage throw nulls the ITEM and skips its remaining stages. CC's determinism bans (`Date.now()`/`Math.random()`/argless `new Date()` throwing) are NOT enforced — they exist for CC's journaling/resume, which this cut defers — so a CC-authored BODY runs unchanged (its meta header moves into the parameter) while scripts written here may freely read the clock. +A workflow call contains JSON `meta` and a JavaScript `script` body with top-level `await`. Metadata is validated as data and never evaluated. The body receives `agent`, `parallel`, `pipeline`, `phase`, `log`, and `args`; failed children and ordinary stage errors resolve the affected item to `null`. Claude Code's determinism restrictions are deferred with journaling, so compatible bodies may use clock and randomness after moving their meta header into the parameter. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. @@ -22,15 +22,15 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre ### The engine (dsh-workflow-workerthread): one worker thread per run -**Trust premise (governs every engine decision below)**: workflow scripts are MODEL-WRITTEN — the same trust level as the model's existing bash access — so the engine defends against BUGGY scripts, never hostile ones. In scope: `result` never rejects, no unhandled rejections from dropped hook promises, loud rejection of values JSON cannot carry, fatal-vs-null hook discipline, cancellation that always frees the caller. Out of scope, deliberately: adversarial values (throwing/spinning accessors, proxies with hostile traps, prototype forgery, `prepareStackTrace` hijack) AND Node-API escape from the script's context — the vm context shares object machinery with its surrounding realm, so a script can reach the `Function` constructor (`globalThis.constructor.constructor`) and from it `process` and every Node builtin; the absent globals are API surface, not containment, and a worker thread is NOT a security boundary (an escapee holds process-wide privileges — Node's permission model is per-process). Worker-side code MAY run script code while reading script values, and that is accepted: a synchronous spin costs the script its OWN thread (terminated at the post-cancel grace), never the host loop, so containing error VALUES would be cost without a threat model. Genuine sandboxing (isolated-vm, a separate process) remains an engine swap behind the seam, not incremental defenses here. +**Trust premise**: workflow scripts have the same trust as the model's bash access. The engine contains buggy scripts and guarantees settled results, JSON-safe values, and cancellation quiescence; it does not defend against hostile code. A vm context and worker thread are not security boundaries: a script can escape to Node APIs with process-wide authority. Sandboxing requires a separate-process or isolated-vm engine behind this seam. -**Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. +**Why `node:worker_threads`**: each run gets one unpooled worker. A vm context limits the documented script surface, while message-port RPC bridges `agent()` to host-side child loops. The worker prevents synchronous script work from blocking the host, provides a serialization boundary, and permits forced termination after cancellation. `isolated-vm` was rejected because of its maintenance state and deployment requirements. -Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. +The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol, and host-owned records preserve the subagent run contract across it. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns the start, cancellation, worker-death, and disposal algorithms. -**Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys. +**Meta is data**: the schema-validated `meta` field reaches the seam as JSON and is only shape-validated. The host never evaluates a metadata literal, which would let script-controlled accessors run outside the worker's isolation. -**Value boundary**: values leaving the script (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a plain recursive walk that rejects loud everything JSON cannot carry (exotic prototypes, functions, symbols, cycles, sparse arrays, non-finite numbers, nested `undefined`), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation; getters are read ordinarily and their RESULT crosses (a throwing read fails loud) — which is also what makes every later postMessage hop total. Values entering the realm (`args`, `agent()` results, hook promises and failures, combinator arrays) are handed over directly as worker-realm values — the script is trusted, so outer prototypes are not a leak; `args` rides the `workerData` structured clone (the caller-isolation copy) and is cloned once more so a script scribbling on it cannot mutate the session's init object. Hook failures are `WorkflowError`s built OUTSIDE the script's context: the combinators recognize fatality by `instanceof` against the engine's own class (unforgeable from the script), and the script-visible consequence — in-script `instanceof Error` is `false` for hook errors; branch on `e.name`/`e.code` — is documented in the engine README. Realm functions (stages, thunks) are called, never materialized. Thrown script values are rendered by a total renderer (stack → message → `String()`, fixed label if rendering throws), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, max(1, availableParallelism() - 2))`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals. +**Value boundary**: `materializeFromRealm` copies outbound values and rejects unsupported JSON shapes, exotic prototypes, cycles, sparse arrays, and non-finite numbers. Data-property copies make `"__proto__"` safe. `args` crosses through `workerData` and is cloned again before exposure. Realm functions are invoked rather than copied, and thrown values use a total renderer so `result` cannot reject. The engine README documents cross-realm errors. ### The consumer (dsh-tool-workflow) @@ -65,8 +65,8 @@ An output schema makes a schema-valid committed capture mandatory for successful - **`SchemaSpec` as the outputSchema type**: the author-facing DSL cannot express what arrives as data and cannot be validated against without conversion loss. - **A schema-object library (zod, or the repo's schemastery) for the structured-output subset**: the schema is wire data — plain JSON that crosses the vm realm boundary in `agent({schema})` and lands verbatim in the forced tool's parameters — exactly where live schema objects cannot sit; consuming raw JSON Schema at runtime would need a third-party converter on top (zod core only emits JSON Schema, not the reverse), and it would put a second schema language beside schemastery's config role. - **ajv for value validation**: it validates FULL JSON Schema, so the subset gate — the module's actual point, since every accepted keyword must be one the harness enforces — would remain hand-written regardless; it compiles validators through `new Function`; and it would be dsh-tools' first runtime dependency, all to replace the ~70-line value walker while the path-qualified, every-violation error reporting stays custom either way. -- **Provider JSON mode (`response_format: {type: json_object}`) instead of the forced capture tool**: the official API guarantees valid JSON, not schema-conforming JSON (no `json_schema` type; the docs' own guidance is to validate client-side, with the schema riding in the prompt), so both walkers survive untouched and only the capture-tool mechanics could go — at the cost of tools during a structured child's run (whether `response_format` composes with tool calling is undocumented), the in-turn validation retry (`ToolArgsError` keeps recovery inside the turn; a JSON-mode empty body — a documented failure mode — ends the turn, and the only recovery is the re-prompt loop this design rejects), and a new per-adapter `LlmCallConfig` surface. The accepted upgrade path is strict TOOL schemas (provider-side constrained decoding on tool parameters) when available: the same forced tool and subset gate, with the gate narrowed to the provider's strict subset. +- **Provider JSON mode instead of the capture tool:** it guarantees valid JSON, not schema conformance, and its interaction with tool calling is unclear. The capture tool preserves in-turn validation retries. Provider-side strict tool schemas can later narrow the accepted subset without changing this design. ## Consequences -The harness gains CC-compatible script orchestration: fan-out plans live in a rerunnable artifact instead of the parent context, and `outputSchema` yields an authoritative structured child result across native and Code Mode presentation. The cost, bounded by the trust premise, is a worker thread per run (~tens-of-ms spin-up), every hook crossing a message port as RPC, and a termination-path `agentsStarted` that degrades to the host-observed count; in exchange `start()` never blocks the host, a post-cancel grace ends in a real `worker.terminate()`, and the value boundary is serialization by construction. A worker thread is still not a security boundary — scripts share the model's trust level, and actual sandboxing requires an isolated-vm/separate-process engine behind the seam. The fatal-vs-null strictness divergence from CC means a CC-authored script that relies on option typos dissolving to `null` behaves differently, preserving the repo's no-accepted-then-ignored rule. Consumers must hold the run handle for control (`cancel`/`dispose`); observers get data snapshots only, so no listener can extend a run's lifetime or corrupt another's view. +Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 5d82226a96..c052fee685 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -49,44 +49,39 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. +After validation and an `approval/asked` append, `request()` resolves to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. The service borrows the readonly request, runs the answerer waterfall, races cancellation, and normalizes thrown or invalid answers to `unavailable`. It then appends the matching `approval/decided`, paired by `ApprovalRequestId`. -Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates. +Both audit events must be inside an open turn; acceptance or a pre-commit append failure rejects the request. Post-commit observers are contained by the session. `allowed-once` grants only the requested action, and the service retains no grant state. -`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. +Answerers are `approval/request` waterfall listeners. A listener returns an outcome for an agent it owns and calls `next()` otherwise. With no answerer, the default is `unavailable`; unloading a UI therefore fails closed without leaving a channel. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and uses `prepend` only for decide-or-delegate gates. + +`ApprovalRequest` carries the agent, tool name, optional `callId`, reason, and signal. The agent routes both the prompt and audit events. The request uses `dsh-llm`'s `CallId` without importing `dsh-tools`, avoiding a package cycle. Tool arguments are omitted because UI answerers attach to the already-rendered call. #### Ask routing in dsh-tools -`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to. +`ToolRegistry.execute()` sends `ask` through the approval seam before the deny path. Only `allowed-once` proceeds; rejection, cancellation, and an unavailable channel produce distinct model-visible reasons. The registry looks up the optional service per call, so an absent or unloaded service fails closed without gating the registry fiber. Agent-less execution also fails closed because it cannot be routed or audited. #### The per-session policy tier -The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. +The seam owns the session policy `'ask' | 'never'`, following the switching contract in the [sandbox RFC](2026-07-06-sandbox.md). The effective session or config policy is applied before answerers: `'never'` rejects inside `request()`, while `'ask'` dispatches and falls through to `unavailable` when unanswered. The prompt states only deterministic `'never'`; the narrator reports switches, and every request still receives its audit pair. #### The ACP answerer -The bridge registers the first real answerer: it resolves the owning session through its existing `WeakMap` reverse map, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. +The ACP bridge finds the owning session, sends `session/request_permission` for the `callId`, and maps one-shot allow, reject, and cancel responses to the seam vocabulary. Unknown selections never grant. Foreign agents and requests without a `callId` delegate via `next()`; RPC failure becomes `unavailable`. The bridge answers requests but does not decide which calls require approval. The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. +`approval/asked` and `approval/decided` are durable log-only events. The model sees only the asker's logged `tool/result`. Every accepted request appends one matching decision, including cancellation and contained answerer failures. #### Entities and dependencies -One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (event-map merge + append), `dsh-agent` (the `Agent` type), `dsh-llm` (`CallId`, via `dsh-brand`). `dsh-tools` and `dsh-acp` each peer on it; the escalation phase's asker lives in `dsh-tool-bash` (see [the sandbox RFC](2026-07-06-sandbox.md) § Escalation), so the sandbox family keeps its ZERO-edge relation (the executor contributes the per-call override mechanism, and transport seams never ask humans questions). The seam is one package, not the capability-seam three: the service body (dispatch + audit) has no replaceable implementation — the replaceable part is the answerer listeners, and those live with their owners (the bridge; future terminal UIs; test scripts). `@cordisjs/plugin-capability` stays orthogonal (a static grant registry answers "is this already authorized", not "ask the user now"), and `subagent-acp`'s child-side `permission` auto-answer is untouched — routing a child's approvals to the parent session is deferred (§ Deferred). - -### Testing - -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. - -Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). +`dsh-user-approval` owns the fixed dispatch-and-audit mechanism; `dsh-tools` asks and `dsh-acp` answers. Replaceable answerers remain listeners in their channel-owning plugins, so a three-package capability split would add an empty implementation layer. Sandbox executors remain transport-only, and static capability grants remain separate from interactive approval. ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). -- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier. - **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered @@ -100,14 +95,9 @@ Snapshot tier: the harness accepts scripted permission answers (`permissionAnswe ## Consequences -The implemented contract is pinned by the suites in Testing: - -- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. -- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). -- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. -- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. -- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. +- Only `allowed-once` dispatches an asked-about action; absent, rejected, cancelled, or failed answer paths deny. +- Session ownership routes prompts, policy, and audit events without crossing editor sessions. +- Accepted requests append one durable audit pair; the model sees only the resulting tool result. Costs and accepted limits: diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 39345c2b53..9915cd91a8 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue. +Model-facing tool order followed plugin registration order, which depends on concurrent module loading for otherwise independent plugins. That race produced different request headers in CI and snapshot recordings. Because order affects request bytes, caching, and the durable header, it needs an explicit deterministic policy. ## Decision @@ -17,7 +17,7 @@ The system-prompt assembly owns the canonical model-facing tool order, exactly w - The list must contain the rest entry exactly once and no duplicate names. - When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration. -The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. The waterfall therefore starts from one deterministic list; when a listener leaves that order intact, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check inherit it with no new loop change. +`assemble()` canonicalizes provider tools before the `system-prompt/assemble` waterfall, removing registration-order variance at its source. The waterfall starts from this deterministic list; unchanged order then flows into the request header, frozen request, and reconstruction checks without loop-specific ordering logic. Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay). @@ -38,12 +38,7 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - Every registry-built assembly starts with a deterministic tool order on every host; absent an expert listener that deliberately changes it, every `request/header` event and model request inherits that order. The CI-vs-local registration-order flip is structurally gone, and the default is lexicographic. - The initial `PromptAssembly.tools` is canonical, so waterfall listeners start from the model-facing order; provider registration order is observable nowhere before that cooperative seam. -- The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design. - A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve. - The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched. - A misspelled or unloaded tool name in `toolOrder` fails the turn at prompt assembly, not the boot: the loop assembles inside the turn (after `turn/start`, before `step/start`), so the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason carrying the message, `agent/error` mirrors it, no step opens, no `request/header` is logged, no request reaches the adapter, and the agent returns to idle. Every turn fails identically until the config is fixed; the process itself stays up (matching the repo rule that explicit config references must not be silently ignored — the enforcement point is the assembly because no earlier universal moment exists). - A tool provider that returns the reserved rest-entry name has the same prompt-assembly failure shape as an unknown listed name. This keeps the sentinel from becoming an ambiguous real tool and preserves the "never drops a tool" ordering contract. - -## Testing - -Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index d1ed071589..057d6cde59 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -34,31 +34,10 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests. -What the model then experiences: denied file effects come back as result facts with a `[sandbox: file access denied under mode]` marker plus standing instructions not to retry around them; under a confining executor the schema offers `sandbox_permissions` + `justification` for the one-approval escalated retry (validated strictly wider than the session's effective mode at execution); the system prompt deliberately does NOT state the sandbox mode — the model learns the boundary from the marker (which names the mode) when it hits it, instead of preemptively refusing work a standing declaration discourages. What an ACP editor experiences: a `sandbox-mode` and an `approval-policy` config-option select per session (each advertised only when its knob is composable), switchable at runtime; a sandbox switch simply changes what subsequent commands may do, while an approval-policy switch to `'never'` is stated in the prompt and narrated. - -The product path, concretely (the escalation arc is verbatim from the recorded `escalation-approved` scenario; the denial leg is pinned on the real-kernel e2e tier): - -``` -tool/result … [sandbox: file access denied under read-only mode] ← the write RAN; the kernel refused it -tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt", - "sandbox_permissions": "workspace-write", - "justification": "the user asked to write escalated.txt in the workspace"} - → the editor is prompted on this very call (session/request_permission through the approval seam); Allow once -tool/result "escalated" — THIS call ran under workspace-write and its result facts say so; the session stays read-only -``` - -Reject instead and nothing executes: the result is the verbatim `the user rejected escalating this command to "workspace-write"`, and the teaching makes that final — no re-ask. +Denied file effects return a marker naming the effective mode. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved wider retry. The prompt does not announce sandbox mode, avoiding preemptive refusal. ACP exposes composable sandbox-mode and approval-policy selectors per session; only the deterministic `'never'` approval policy is stated in the prompt. ### Design detail -#### Grounding — verified against the code - -- Runtime OS subprocesses exist at exactly two sites: the `ctx.bash` seam's single spawn (`packages/bash/bash-local/src/run.ts`; hook commands flow through `ctx.bash`, so bash confinement covers them transitively) and `subagent-acp`'s child agents (`packages/subagent/subagent-acp/src/run.ts`) — the second consumer that makes a shared seam due rather than preemptive under the [capability seams RFC](../architecture/2026-06-13-capability-seams.md)'s "don't split preemptively" rule. -- Everything else executes inside the harness process (fs is in-process `node:fs`, web is in-process `fetch`, every `ToolDefinition.execute()` closes over `ctx`): an OS sandbox wraps `execve` and cannot wrap an in-process function call, so "sandbox any tool" is policy at each tool's seam, never a mechanical transport change. -- `tools/pre-execute` (`allow`/`deny`/`ask`) exists, with `ask` serviced by [the approval seam](2026-07-06-approval-seam.md); the fs intent gates are version guards with no mode input yet. -- `dsh-bash`'s request/spec split (`BashExecRequest` → `resolve()` → `BashExecSpec`) carries per-call fields the way escalation needs — `owner` is the template: request-optional, spec required-but-nullable, carried verbatim — and the result types already speak `SandboxMode`, so a per-call policy field adds no dependency edge. -- The pinned-header snapshot design means a schema/description change churns at most one pinning fixture per suite, and the escalation fields are advertised only under a sandboxing executor — so they live in exactly one pinned header, the sandbox example suite's `mode-switching` fixture. - #### The seam: `ctx.sandbox` `dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root). @@ -75,11 +54,11 @@ Left open, for the phase that needs them: whether network restriction arrives as The launcher is a ~300-line C program (plain C11 over the raw Landlock UAPI — no libraries beyond a statically linked musl, so the audit surface is that one file plus the kernel's stable syscall contract): `--ro ` / `--rw ` grants, `--`, the wrapped argv; it installs the ruleset on itself and `exec`s (rulesets are inherited across `execve`, and it sets `no_new_privs` before restricting); `--probe` enforces a maximal ruleset in a short-lived child and exits 0 only when the kernel actually enforces; launcher failures exit 125 without exec'ing. -The launcher lives in its own repository and reaches the harness as the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) (the per-platform-package pattern of `node-addon-require-builtin` and esbuild): an entry package — `dsh-sandbox-local`'s one runtime dependency — plus per-platform binary packages selected at install time by npm's `os`/`cpu` fields. The entry package owns the launcher's CLI contract end to end (`launcherPath()` resolution with a never-existing fallback, the functional `probe()`, `grantArgs()` flag spelling), versioned together with the binary so probe-report parsing can never drift against it; the harness keeps only the policy side, `landlockProfileArgs()` mapping the mode vocabulary to grants. Native-only per-architecture builds, pack gates (binary presence, executability, ELF architecture), and the byte-pinned publish rehearsal are that repository's release pipeline; this repo's Landlock CI legs install the published family from the registry — the true consumer path — and prove real-kernel confinement through it. +The Landlock launcher ships through [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run), with platform binaries selected by npm. That package owns path resolution, probing, and CLI flags; the harness maps sandbox modes to grants. Versioning the entry point with its binaries keeps probe parsing and launch syntax aligned. FIXME: Revisit the separate-repository boundary and try to maintain the launcher source and its platform package family inside this monorepo, so the native release surface and harness contract evolve together. -Profile parity is honest rather than identical: under Landlock, `read-only` grants `--ro /` plus `--rw /dev/null` (the node, not `/dev` — the host's `/dev/shm` is a persistent shared tmpfs), and `workspace-write` grants the HOST `/tmp` where bwrap's is ephemeral; under Seatbelt, `read-only` likewise grants only the `/dev/null` literal, and `workspace-write` grants the host `/tmp` plus the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools; omitting it would deny what the mode promises). Every wrap carries the rung's denial dialect (`denialSignatures`: EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) so consumers match the active backend rather than a cross-runner union. Enforcement is honest per ABI level: an older kernel enforces the subset its ABI governs (path truncate is ungoverned before ABI v3), the probe's report line distinguishes the cases, and every confined result carries the structured `enforcement: 'full' | 'partial'` fact — refusing partial enforcement would deny the fallback to precisely the older-kernel hosts that need it. The bwrap and Seatbelt profiles govern every promised file effect by construction, so their passing probes always report `full`. +Backend profiles share the mode contract but differ in necessary host grants. Landlock and Seatbelt allow only `/dev/null` in read-only mode; workspace-write also permits their required host temp roots. Each wrap carries backend-specific denial signatures. Landlock reports partial enforcement on older ABIs that cannot govern every operation, while successful bwrap and Seatbelt profiles report full enforcement. #### The bash consumer @@ -89,7 +68,7 @@ The model's view is result facts only: the static tool description explains the #### Escalation: one approved wider retry after a denial -The seam level is mechanism only. `BashExecRequest` carries `sandboxMode?: SandboxMode`, an explicit per-call policy input; `BashExecSpec` carries it required-but-nullable (the `owner` pattern: a forgotten field is a visible `undefined`, and `resolve()` is the one explicit defaulting step); `BashExecutor` exposes the capability fact `get sandboxMode(): SandboxMode | undefined` — `undefined` in the base class, the configured mode in `SandboxBashExecutor` — so the tool layer can advertise only what the mounted executor honors: composition truth, not configuration. The seam honors ANY explicit mode, including a narrower one; the wider-only ladder is escalation policy and lives in the tool. A non-sandboxing executor (`dsh-bash-local`) carries the field verbatim and confines nothing — the field reaching it means the caller bypassed the tool's gate, and its honest behavior stays unconfined execution, not a guess at enforcement it does not have. +`BashExecRequest.sandboxMode` is an optional per-call input; resolved specs make the field explicit. `BashExecutor.sandboxMode` advertises whether the mounted executor can honor it, so only a confining composition exposes escalation. The seam accepts any explicit mode; the tool owns the wider-only escalation rule. Non-sandboxing executors remain honestly unconfined. `SandboxBashExecutor.resolve()` stamps the effective mode — escalation grant > session override > configured default — so `run()`/`start()` read the spec, never the config. The `danger-full-access` branch, the confine call, and the result facts all key off the spec's mode, and the per-task facts map carries each task's mode alongside its wrap facts (`notifyTaskDone()` stamps from the map entry): one escalated call — foreground or background — reports the mode it ACTUALLY ran under while every neighbor keeps its own. @@ -122,7 +101,7 @@ Sandbox mode is not narrated in the prompt; denial results report the mode when **The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. The bridge advertises one independent `select` per composable knob — `sandbox-mode` (category `mode`) iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with `currentValue` folded from each session's own log, in `session/new` and `session/load` responses. `session/set_config_option` validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract). -**Anchoring: turn-enclosure is the commit boundary.** The turn-enclosure contract makes a bare between-turns append invalid (the JSONL backend treats a post-`turn/end` tail as crash garbage; dev invariants throw). A switch while a turn is open appends immediately — openness read from the LOG (last boundary event is `turn/start`), not `agent.status`, which stays `running` between queued turns. An idle switch is held on the bridge's session record and anchored at the next turn's `agent/prompt-submit` — inside the turn, before anything in it assembles or executes, last write per knob, and OUTSIDE any `session/event` emit (appending from inside that feed reorders events for later-registered listeners — a bug the dev invariants caught live). Until anchored, the switch exists only in bridge memory: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth, so the editor UI self-corrects rather than lies. +**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Until then responses overlay the pending value. A crash discards it, and reload returns the durable fold. #### In-process tools @@ -130,13 +109,6 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam. -### Testing - -- Unit tier (no real runner anywhere): profile dialects, per-platform chain selection (sole candidate unprobed, no chain fails closed, multi-candidate probe order), verdict caching, the fail-closed end, probe-report parsing, and the launcher/`sandbox-exec` CLI contracts via fake runner scripts in `dsh-sandbox-local`; wrapping, policy hand-off, fact stamping, and runner-failure-outranks-denial classification (foreground throw, background `runnerFailed` fact) against a fake provider in `dsh-bash-sandbox`; the error's structured identity in `dsh-sandbox`. The escalation matrix spans the three bash packages: verbatim carry-through in `dsh-bash-local`, stamp/branch/per-task-facts in `dsh-bash-sandbox`, and the capability gate, `justification` pairing, fail-closed texts (pinned verbatim), and grant stamping in `dsh-tool-bash`. The switching surface pins the folds, the stamping precedence, the `'never'` gate, per-session section rendering, the full narrator matrix (cold start, coalescing, net-zero, resume drift with operator wording, positional attribution, persona-shadow hardening), and the bridge's advertisement gating, validation rejections, idle-vs-mid-turn anchoring (dev invariants mounted), and `session/load` reporting over a real two-process JSONL round trip. -- Keyless real-runner e2e, split along the seam and per rung: CI's `sandbox-e2e` matrix runs bwrap and Landlock on Linux (the Landlock leg once per architecture, each confining through the registry-installed launcher) and Seatbelt on macOS against real kernels, failing on a silent all-skip. World-proofs live in `dsh-sandbox-local` (denied writes absent on disk, workspace writes landing, temp-area grants pinned, kernel denial text matching the advertised dialect) and `dsh-bash-sandbox` (the through-`ctx.bash` consumer proofs, including denied-then-overridden-write-lands). This package's own publish path is rehearsed without publishing (`packed-install.e2e.ts`): `pnpm pack`, tarballs installed into a throwaway consumer with the launcher family resolving from the registry, plain `node` confining through the INSTALLED launcher — asserted executable apart, so a mode-stripped binary can never masquerade as a non-enforcing kernel. The switching surface has its own keyless e2e (`examples/sandbox-acp-agent`): the real `cordis.yml` tree advertises both options, honors switches end to end, and rejects out-of-vocabulary values. -- With-key e2e (`examples/sandbox-acp-agent/tests/escalation.e2e.ts`): real model + real runner + the REAL bridge answerer, world-verified — denied under `read-only`, escalates with justification, the scripted editor grants and the retried write lands on disk, while a rejected escalation leaves no write. Self-skips without `DEEPSEEK_API_KEY` or a usable runner (e2e.yml installs bubblewrap so it actually executes in CI). -- Snapshot tier (`examples/sandbox-acp-agent/tests/acp.snapshot.ts`): the keyless config-option wire; the recorded mode-switching arc as the suite's pinned header — necessarily, since mid-session switches emit the `request/header-delta`s the uniformity guard licenses only in the pin — committing both switches, the prompt-section delta and one "changed by the user" notice per knob, and a confined write landing under the switched mode; and both recorded escalation branches over scripted `permissionAnswers` (grant runs confined under `workspace-write`; rejection executes nothing and pins the fail-closed text). Replay re-executes every fixture's bash calls under the host's real runner (ci.yml's snapshot lane installs bubblewrap). Deliberately absent: a fixture carrying a real DENIAL — denial stderr is the backend's dialect and would pin a fixture to its recording platform; the escalation prompts assert the prior denial instead, and the denial→marker path stays on the tiers above. - ## Deferred phases Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. 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 6f81d12407..dfbf822ccb 100644 --- a/docs/rfc/implemented/feature/2026-07-07-session-prefix.md +++ b/docs/rfc/implemented/feature/2026-07-07-session-prefix.md @@ -20,10 +20,6 @@ Three properties carry the design: 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, zero `request/header-delta`s), 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 codec tests cover the `messagePrefix` fold/diff/apply arms (empty ≡ absent); 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. - ## 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 silent drift — nothing anchors it to the log short of logging a header delta per step — 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. diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 9d0446dcad..a7d7117aeb 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -10,7 +10,7 @@ The harness already has every seam the pi extension uses, and better ones: [the ## Decision -The guard is a loop-hygiene plugin, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The purpose is to break unproductive loops within a few wasted steps instead of letting them run to the turn's natural end — while leaving the decision (retry differently, gather more evidence, or finish) entirely with the model, so a legitimately repeated call is delayed by nothing and blocked by nothing. +The guard is a loop-hygiene plugin, not a model-facing tool. It counts consecutive calls to the same tool with identical canonical arguments and injects advisory reminders at configured thresholds. It never delays, blocks, or rewrites a call; the model decides whether to retry differently or finish. The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. @@ -29,7 +29,7 @@ Two deliberate rules, both documented in [the package README](../../../../packag ### Reminder delivery -Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered context as `context/message`(s) after the step's results, which the session renders as the tagged synthetic-user envelope and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at `argumentsPreviewChars`, default 500 — a looping `write`-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. When the downstream decision already carries `additionalContext` (a hook bridge on the same call), the guard concatenates content under its own `source` — a `HookContext` holds one `MessageSource`, and `source.kind` is what framing depends on. +Reminders use `additionalContext` with the plugin source, preserving the original `tool/result`. The first threshold emits a short nudge; later thresholds include the tool, count, and a bounded argument preview while comparison still uses the full canonical string. Existing downstream context is concatenated under the guard's source because `HookContext` supports one source. ### Config @@ -45,10 +45,6 @@ Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-too `thresholds` is validated at load and throws on an empty list, a non-integer, a value below 2, or a duplicate — misconfiguration fails loud, replacing the pi original's silent fall-back to defaults. `include`/`exclude` entries support `*` wildcards. Patterns are predicates over whatever tools exist at call time, not references to a registry entry, so an entry matching no currently registered tool is NOT an error — unlike `toolOrder`'s referent check, `exclude: [mcp_*]` must stay valid in a deployment that loads no MCP tools. -## Testing - -**Unit** — the suite drives a real agent loop against a scripted mock adapter (no network) and covers, at per-file 100%: counting/reset semantics (identical, different-tracked, untracked-transparent, prompt-submit reset, disposal cleanup, per-agent isolation), canonicalization (deep key-order insensitivity), threshold escalation including the `thresholds[0]` gentle-text rule, denied-call counting, no-agent transparency, wildcard escaping, config fail-loud cases, and both fold-onto-downstream paths (block and accept-with-replacement). **Snapshot** — the `repeat-tool-guard` scenario in the acp-agent example suite scripts five identical `todo_write` calls and pins both reminder tiers (gentle at the third, detailed at the fifth) as `context/message`s in the ACP transcript and the session log; the guard is loaded in the example's live tree (`cordis.yml`), inert for every other scenario (none repeats a call three times). The scenario is authored keyless (like `error-finish`/`cancel`): deterministically forcing a live model to repeat one call three times is not a stable recording. **e2e** — none: the plugin is provider-independent and deterministic, and the seam contracts it relies on are e2e-covered by their owners. - ## Alternatives considered - **Append the reminder into the tool result** (`accept` with replaced `content` — the pi extension's mechanism, which patches result content because that is the only channel its API offers) — rejected: it makes the logged `tool/result` lie about what the tool returned, and `additionalContext` exists precisely as the separate sanctioned channel for post-execute commentary, with loop-level buffering that preserves call/result adjacency. @@ -64,7 +60,6 @@ Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-too - The reminder is advisory by design: idempotent polling patterns that repeat identical calls on purpose still receive nudges past the thresholds, and the pressure valves are config (`thresholds`, `exclude`) plus reminder text that explicitly allows finishing when enough evidence has been gathered. Each trigger costs reminder tokens on the next request; thresholds bound the frequency. - Chain state is in-memory only: a session resumed from persistence starts with a fresh chain, so a loop spanning a resume draws its reminders later than a live one — accepted, the guard is a heuristic nudge, not a logged invariant, and persisting counter state would buy little for real complexity. - When multiple post-execute producers attach context on one call, the fold concatenates under the guard's `source`; ordering between plugins follows listener registration order. The seam cannot represent mixed provenance — a limit inherited from `HookContext`, not owned by this plugin. -- Implementing the snapshot tier surfaced a hidden assumption in the suite kit: the fixture guard equated "authored model scenario" with "override-driven". The `Scenario` table now carries an explicit `overridden` flag, and the sidecar's presence is checked BOTH ways against it (an unregistered stray sidecar would silently replace the derived script) — the suite kit is stricter than it was before this plugin existed. ## Deferred 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 00f9fb041c..bcecbc0dfa 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 @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again. -The trust stance, stated once and threaded through the rest: the `node:vm` sandbox isolates the global context only — it prevents accidental global pollution, not malice — and the `ctx` a mounted plugin's `apply` receives is a whitelist façade that narrows the *surface* (framework internals withheld) but not the *privilege* of what it exposes. The verbs the façade does expose reach the real runtime: a mounted tool can shell out through `ctx.bash`, read the filesystem through `ctx.fs`, reach the network through `ctx.web`. Neither the sandbox nor the façade is a security boundary; handing the model this power is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's `cordis.yml`, never a product default. +The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: mounts can reach real bash, filesystem, and web capabilities. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. ### The three tools @@ -26,17 +26,17 @@ The trust stance, stated once and threaded through the rest: the `node:vm` sandb ### Sandbox semantics -Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of an async function under a per-mount filename (`cordis-mount-.js`). The vm gives the code a fresh realm: writes to `globalThis` stay inside the sandbox, and no Node API is handed in — capability access is *steered* toward the cordis services (`ctx.fs` for files, `ctx.web` for HTTP, `ctx.bash` for processes, the `ctx.timer` helpers for timing) rather than Node built-ins, so a well-behaved mount stays inspectable through `cordis_inspect` and disposable with its fiber. This is steering, not containment: consistent with the trust stance above, the small global surface keeps *honest* code on the cordis services but is not a security boundary — the host-realm helpers it exposes (`harness`, `console`, `btoa`) are reachable functions, so mount code that goes looking (through such a helper's `.constructor`, say) can still reach the host realm and Node itself, which is accepted because the `ctx` a mount ultimately receives is fully privileged anyway. The `vmTimeoutMs` config bounds only the synchronous portion of evaluation; an async body escapes the bound (also acceptable under the trust stance). +Mount code runs as an async-function body in a fresh vm realm. Its documented surface steers file, network, process, and timer access through Cordis services so mounts remain inspectable and disposable. Host-realm helpers still make Node escape possible, consistent with the trusted posture. `vmTimeoutMs` bounds only synchronous evaluation. Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor. Mount code crosses the vm boundary through three controls. Dual-realm `instanceof` recognizes both host and vm objects. `harness.defineTool` normalizes results into host-realm JSON and validates the `ToolExecuteReturn` shape before logging. The mounted plugin receives a whitelist context façade, not a raw or pass-through `Context`; framework plumbing and context-valued returns are rejected. Service reads require a declared `inject`, preserving Cordis activation and unload semantics. `ctx.tools.get` exposes only the schema view, so mounted code cannot bypass `ToolRegistry.execute` by calling a definition directly. -Boundary errors are written around the mistakes models actually make (see [Consequences](#consequences) for how each was found), and the boundary normalizes rather than lectures wherever the input has exactly one meaning: schema `parameters` accept the JSON-Schema dialect models write by strong prior — the `{ type: 'object', properties, required: […] }` wrapper unwraps to the SchemaSpec DSL (the `required` array becoming per-property flags, at any nesting level), `type: 'integer'` maps to `number`, and `required: false` reads as optional — while genuinely meaningless input is rejected with the vocabulary enumerated (an unknown type lists the five valid ones; a non-boolean `required` names the rule). The remaining teaching errors: an unbalanced `});` closing gets the vm's offending source line plus a "code is a function body" reminder; TypeScript syntax gets the remove-annotations fix (detected on the failing line only, so an ` as ` inside a description string does not misfire); a forgotten `return` gets the two valid plugin forms; a Node built-in call gets the redirect to its cordis service; a tool-name collision on re-mount gets the unmount-first-then-remount recipe. +The boundary normalizes unambiguous JSON-Schema forms into `SchemaSpec`, including object wrappers, `integer`, and optional fields. Invalid vocabulary fails with the accepted alternatives. Parse, TypeScript, missing-return, Node-API, and duplicate-tool errors include the relevant source line or corrective contract without narrating implementation internals. ### The dynamic group and mount lifecycle -Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself a child of the `tool-cordis` plugin's fiber. The group exists so the mounts form one subtree: they are disposed as a unit, and disposing `tool-cordis` (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is `await()`ed, and a startup error (a throwing `apply`, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied `inject` — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so `cordis_unmount` is nothing but an awaited `fiber.dispose()`. +All dynamic mounts are children of one `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles reload and unload. Mounting awaits settlement; startup failure disposes the fiber before returning an error. A settled pending mount remains visible with its missing injections. `cordis_unmount` awaits the mount fiber's disposal. ### Cross-mount composition via provide/inject @@ -44,7 +44,7 @@ Mounts relate to each other through ordinary cordis service semantics, with thei ### The generated API catalog -`cordis_inspect what:"api"` and `what:"events"` answer from a machine-readable catalog generated at build time, never a hand-maintained table that would drift from the JSDoc it paraphrases. [`scripts/gen-cordis-api.ts`](../../../../scripts/gen-cordis-api.ts) reuses `collectServices` / `collectEvents` from [`scripts/gen-cordis-catalog.ts`](../../../../scripts/gen-cordis-catalog.ts) — the same AST walk that generates [the cordis service catalog](../../../cordis-catalog/services.md) and [events catalog](../../../cordis-catalog/events.md) — and emits `packages/cordis/tool-cordis/src/api-catalog.ts`, a committed, banner-commented data module. The artifact carries, per service, its key + one-line summary + raw method signatures; per event, name + `@mode` + signature + summary; the comment-stripped declarations of every exported type the service signatures reference (transitive closure — so a consumer sees that a bash run's `stdout` is `{ text, truncated }`, not a string); plus the curated inherited `ctx` surface shared with the cordis catalog generator. A type name declared in more than one package (each plugin's `Config`) is dropped as ambiguous, and an oversized declaration is truncated with a marker. +`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. 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. @@ -78,7 +78,3 @@ The correctness investment therefore goes where it pays for every capability at ## Consequences The toolset is a deliberate opt-in with a fully-privileged `ctx`, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. `tools/pre-execute`) that returns without calling `next()` vetoes the chain, so a mounted listener can lobotomize the agent's own tool dispatch ([waterfall semantics](../../../cordis-primer.md#cordis-waterfall-semantics)); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; `vmTimeoutMs` bounds synchronous evaluation only; and mounts do not survive session resume. - -The instructive boundary errors were not guessed — they were written against live self-design sessions in which a real model was asked to build itself coding tools. Those sessions surfaced the failure modes now mitigated: the model closed a returned plugin object with `});` and got only a bare `Unexpected token ')'` it retried blind; it hit a false-positive "this is TypeScript" hint because a description string contained the word "as"; it guessed a bash run's `stdout` was a string and burned six steps building throwaway debug tools to discover it is `{ text, truncated }`; and it wrote tool schemas in the JSON-Schema dialect (`type: 'integer'`, `required: false`, then the full wrapper) three rejections in a row — the rejection text itself pushing it from a nearly-correct DSL attempt back to raw JSON Schema. The fixes — source-line-plus-caret parse errors, line-scoped TypeScript detection, the type-shape closure in the API catalog, the redirect traps, and schema-dialect normalization in place of rejection — cut later sessions from dozens of tool calls with repeated errors to a first-try success on every capability, including a model that hit a Node-`setTimeout` trap and self-corrected to `inject: ['timer']` in one step. - -Coverage is named per tier: package unit specs drive the three tools through a real `ToolRegistry` on a real fiber tree (the mount success/failure family, vm isolation, dual-realm `instanceof`, realm normalization against the real `isJsonValue`, the SchemaSpec and raw-registration rejections, the Node-API traps, the cross-mount provide/inject matrix, catalog-backed `api`/`events` rendering, config validation, presenters, quiescent unmount, and the HMR cascade), a `MockAdapter` loop test proves a tool mounted in one step is dispatchable in the next, and the example carries a keyless Loader smoke plus a with-key smoke that world-verifies a live model mounting a listener, building its own tool, and composing two mounts. No snapshot scenario is added: the toolset ships in no ACP-served app, so it changes no editor-facing transcript, and its presenters are unit-tested pure functions — adding it to the ACP example solely for a golden would rewrite the pinned request-header tool set of every recorded scenario. diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index f0b175303c..eafce4feae 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -12,7 +12,7 @@ This is the wiring-axis complement to the [core-data-structures catalog](../../. Generate the catalog from source instead of hand-maintaining a table and verifying a subset. -`scripts/gen-cordis-catalog.ts` walks the `interface Events` and `interface Context` declarations (plus the service classes) with the TypeScript compiler API and emits two sibling pages: `docs/cordis-catalog/events.md` (events grouped by scope, each rendered as signature + mode badge + its source JSDoc, plus the dispatch-mode legend) and `docs/cordis-catalog/services.md` (each `ctx.` with its public method signatures + class JSDoc). The two axes are separate documents — a reader is either finding what to listen to or what to call, and each page scans and deep-links as its own reference instead of one long combined scroll. It mirrors the `gen-module-graph` pattern exactly: `--write` regenerates both, `--check` fails if either committed file is stale, output is deterministic (sorted), and the files are build artifacts that are never hand-edited. `verify-cordis-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. +`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes; services include public signatures. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). diff --git a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md index 3e0b719ffd..7d79f73583 100644 --- a/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog.md`, and a freshness gate so it cannot drift. +The repository had no single reference for the names, descriptions, and JSON Schemas actually exposed to the model. Source declarations are scattered and runtime-composed, while the existing Cordis and data-structure catalogs cover wiring and vocabulary rather than tools. ## Decision @@ -27,13 +27,13 @@ Booting has a cost the AST pass did not: there is no source declaration set to e ### A hand-maintained boot manifest is the irreducible policy -The boot manifest (`TOOL_PACKAGES`) is a hand-written list — in tension with the proposed [Discover package inventories instead of maintaining static lists](../../proposed/process/2026-06-20-discover-package-inventory.md). The tension is deliberate and resolved as follows: the *inventory* is discovered (the glob guard means no one maintains "the list of tool packages" — the filesystem is the source of truth, and drift fails the gate), but the *boot recipe* per package — which seams to plug (`bash-local` for `ctx.bash`, `subagent` + `subagent-mock` for `ctx.subagents`) and with what config (`{ provider: 'mock' }`) — is genuine policy that no layout fact encodes. Per that RFC's own "what we give up" ("stay boring: read manifests, filter on explicit fields, print the resolved list, and fail loud"), a recipe closure is the boring, explicit form; inferring seam wiring from injects would be the "too clever" path it warns against. So: discovered inventory, hand-written recipe, gate on completeness. +The filesystem discovers the tool-package inventory and the completeness guard rejects omissions. `TOOL_PACKAGES` still owns an explicit boot recipe for each package because required seam implementations and config are policy, not facts that can be inferred safely from layout or injection names. ### Scope Shipped product tool PACKAGES under `packages/*/tool-*`, each booted with its default config: `dsh-tool-bash` (`bash`, `bash_output`, `bash_kill`), `dsh-tool-todo` (`todo_write`), `dsh-tool-subagent` (`subagent`). The `examples/` demo tools (`echo`) are excluded, matching the cordis catalog's packages-only scope — a demo tool is not part of the product surface a reader is cataloguing. -The unit is the PACKAGE, not the deployed tool instance. A package's registered tool name can be a load-time config — `tool-subagent`'s `toolName` — so the same package surfaces as `subagent` (spawn backend) AND `subagent_fork` (fork backend) in the shipped `coding-agent` / `acp-agent` configs, with an identical schema. The generator boots each package once at its default and records such shipped aliases in a per-package note, rather than enumerating every deployment permutation. Cataloguing at the package level keeps the source of truth the package (what a plugin author reads) and avoids leaking example-app `cordis.yml` config into a packages-scoped generator; the note keeps the doc honest about the names a reader will actually see the model receive. The design deliberately does not attempt to catalog "every configured tool instance across every leaf config" — that is a deployment inventory, a different (and unbounded) surface. +The catalog unit is a package, not every configured tool instance. Each package boots once with default config; load-time aliases such as `subagent_fork` are noted without enumerating every deployment permutation. A deployment inventory is a separate, unbounded surface. ### A plain `json` fence diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 9db59e1138..42ab306bb0 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE. +The generated Cordis catalog enforced event dispatch modes but not complete service and event contracts. Methods could lack descriptions, and parameters or returns could be undocumented on the cross-plugin API surface where IDE guidance matters most. The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run. diff --git a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 5d886fa4bd..3f88e43c1d 100644 --- a/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/docs/rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 50 commits in two and a half weeks — each PR appending its own lesson, none displacing anything — until the same rule was stated two or three times inside one file (the pushed-branch rewrite ban ~600 words across two sections; the with-key e2e policy ~400 words across two), an incident already recorded in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) was retold inline at ~750 words, and the per-package one-liner map existed in five places. [architecture.md](../../../architecture.md) grew the same way: paragraph walls re-narrating RFCs it already links, plus implementation-status annotations that were stale the week after they were written. The writing rules that forbid this (document current state, never history) predate the drift and sat in the very file violating them — prose rules alone do not hold against accretion pressure. The repo's standing answer to an invariant of this kind is a mechanical check ([quality gates](2026-06-11-quality-gates.md), [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)). +Standing docs accumulated repeated rules, retold incidents, duplicated package maps, and stale RFC summaries despite existing writing guidance. Because review alone did not prevent that growth, the repository needed a mechanical budget alongside its documentation taxonomy. ## Decision @@ -24,11 +24,3 @@ The repo's standing docs accrete. Root `AGENTS.md` reached 8,130 words through 5 - Adding to a budgeted doc now requires displacement: relocate the addition to its taxonomy home with a pointer, or condense existing prose to pay for it. Growth without pruning fails CI. - The bring-under-target rewrites land as stacked follow-ups that ratchet the manifest down as they merge; until each lands, its doc's frozen ceiling only prevents further growth. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. - -## Deferred work - -The first audit cycle under the standard, in rough priority order (evidence gathered in the survey that motivated this RFC): - -- Package README trims where generated catalogs or JSDoc are restated or history is narrated: `packages/ui/acp`, `packages/core/tools`, `packages/bash/tool-bash`, `packages/core/session`, `packages/compact/compact-basic`, `packages/session-persistence/session-persistence`. -- `docs/core-data-structures/core.md`: drop the JSDoc walls from the `Agent`/`GenerateOptions` type-equiv pastes per that page's own stated rule. -- [Postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md): merge the overlapping Executive summary and Summary sections. diff --git a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md index 2eaf112878..0de6255322 100644 --- a/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md @@ -10,7 +10,7 @@ Status: implemented Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools). -`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` — log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope. +`gen-persistence-catalog.ts` scans every owning and declaration-merged `SessionEventMap` with the TypeScript AST. It renders source JSDoc, payload type, derived surface badge, reference links, and source location. The doc-sync freshness check rejects a vocabulary change whose catalog was not regenerated. Specific choices: diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md index 696d46cb20..d5ce28066d 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some twenty-seven `Status:` line spellings once free-text rejection reasons are collapsed — bare enums, dated parentheticals duplicating what the filename and git already carry — plus three English files (and the zh counterpart of one of them) with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and nineteen implemented RFCs still carrying thirty occurrences of the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`, `Proposal`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires. +RFC paths encoded lifecycle and class, but file contents still mixed headings, status formats, ADR and proposal templates, and proposal-era sections in implemented records. Authors copied whichever neighbor they found, and lifecycle moves could skip the required rewrite because no gate enforced an in-file contract. ## Decision diff --git a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 2e98808426..6ebb477dea 100644 --- a/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -22,7 +22,7 @@ The contract by declaration kind: Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked): -- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where the override grows surface the base never documented: a protected-only base member does not exempt a public override, parameters the base never names keep their `@param` duty (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter), and a concrete result above a void base return keeps its `@returns` duty (an unannotated override's inferred return is classified by the checker, so a faithful void override needs no boilerplate annotation). Heritage lookups and that one return classification are the walk's only TYPE CHECKER questions (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated). +- **Heritage members.** Overrides inherit documentation from their base declaration. New public surface still requires docs: added parameters, a public override of a protected member, or a concrete return over a void base. Heritage lookup and inferred return classification are the gate's only type-checker work; other checks use the AST. - **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics. - **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story. diff --git a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md index 7693ff074c..999ebd8503 100644 --- a/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md +++ b/docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md @@ -4,11 +4,11 @@ Status: implemented ## Problem -The config surface — the exact set of fields a `cordis.yml` entry's `config:` block can set for each plugin, with types, defaults, and semantics — had no reference page. A deployment author assembling a config tree had to open every plugin's source (or trust its README) to learn what is settable. The per-package README `## Config` sections cover parts of it by hand, in formats that diverged package-by-package (a key/default table here, an annotated YAML snippet there) and with no gate tying them to source. Nothing enumerated which packages are loadable at all — plugin vs abstract seam vs plain library — and nothing verified that the runtime schemastery schema and the documented `Config` interface agree, so a schema-validated field could exist with no documentation anywhere. +The repository had no source-backed reference for plugin configuration. Package READMEs documented fields inconsistently, did not enumerate which packages are loadable, and did not verify that runtime schemas agree with declared config types. ## Decision -Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. The paste is the plugin's full declared config type: a field the runtime schema deliberately excludes is a runtime-only seam, marked as such by its own JSDoc, not a `cordis.yml`-settable knob. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited. +`scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md) from each plugin's declared config type and JSDoc, with injection requirements, referenced-type links, and a source pointer. Package-local types are included transitively; workspace and external types are linked or named. Deterministic `--write` and `--check` modes make the committed page a generated artifact. Pure AST generation is correct here for the same reason it is for the events/services catalog and NOT for the tool catalog: a config type is a static declaration and every schemastery schema in the repo is a static `z.object`/`z.intersect` literal, so the source is the whole truth — nothing about the config surface is runtime-composed. @@ -17,7 +17,7 @@ Specific choices: - **The config type is the second-parameter type.** What the catalog documents is the declared type of `apply(ctx, config)` / the service constructor's `(ctx, config)` — the value cordis actually passes — not a `Config` export located by naming convention. This is what makes the walk total: it works for interfaces named `AcpConfig` or `BasicCompactConfig`, for types declared in a sibling file, and for plugins with no validating schema at all. - **Classification is total.** Every `packages//` entry resolves, mirroring the Loader's `unwrapExports` (`exports.default ?? exports`), to a configurable plugin, a config-free plugin, an abstract seam class, or a library — each rendered in its own section — and an unclassifiable entry hard-errors. A new package cannot be silently undocumented. - **Per-field JSDoc is enforced.** Every property of a pasted declaration (nested type literals included) needs non-empty JSDoc prose, or generation fails. The paste IS the documentation, so this is the same forcing function the events catalog applies via `@mode`: thin source docs fail the gate rather than yielding a thin catalog. -- **The schema is cross-checked, one-directionally, nested keys included.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys and their nested object/array compositions as key paths (`agents[].id`), chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated key path must be locatable on the declared config type, resolving package-local and workspace-imported types (re-export chains included), intersections, unions, utility wrappers, and indexed access. So the paste cannot hide a loader-accepted field, top-level or nested. The check is presence-only and fails loud only on a definite miss: a path crossing a type the walk cannot enumerate (an external package's type) is skipped rather than mis-reported, and dynamic-key shapes (`z.dict`) or union alternatives contribute no nested paths. The reverse direction is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`). +- **Schema keys are checked against the declared type.** The generator resolves nested object and array paths through local and workspace types. Definite missing paths fail; external or dynamic shapes that cannot be enumerated are skipped. The check is intentionally one-way because declared types may contain runtime-only fields excluded from loader config. - **A dedicated fence.** Pasted declarations use a ` ```ts config-catalog ` info string that `doc-typecheck` skips (a lone declaration referencing imported types is not standalone-compilable), excluded from the opt-out ratio — the same treatment the `cordis-catalog` and `persistence-catalog` fences get. - **A single file at `docs/config-catalog.md`**, not a one-file directory: the page serves one audience (the `cordis.yml` author) with one axis, unlike `cordis-catalog/`, which holds two sibling pages. diff --git a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md index 061d534b9b..be31a439c1 100644 --- a/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ b/docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md @@ -10,15 +10,15 @@ The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and ## Decision -[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The compatibility matrix has Node 22.19, 24, and 26 jobs; each installs once and runs `pnpm run check:node-compat`. +[CI](../../../../.github/workflows/ci.yml) groups keyless checks into broad primary-runtime lanes plus a compatibility matrix. The workflow file owns the current lane and runtime inventory. -Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers. Every compatibility job runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke, which starts a real unbuilt worker and therefore catches Node-version-specific loader/runtime failures that typechecking cannot. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log. +Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), which schedules independent gates with bounded concurrency and prints an attributable result block for each one. Artifact consumers depend on one build within their lane, while compatibility jobs combine typechecking with a real unbuilt worker launch to cover runtime-specific loader behavior. Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane. Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report. -Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=14` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split. +Both workflows cache the pnpm store. The real-API workflow uses the shared bounded Vitest file pool rather than a separate job per test group. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md index 0a80ec5a41..a93e3d3196 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-collapse-trace-only-session-events.md @@ -37,6 +37,6 @@ Shipped as proposed, with one scope refinement (per AGENTS.md "RFCs are proposal - **Empty-content `assistant/message` hosts usage with no data loss.** The proof the proposal demanded (no persisted usage chunk becomes unrepresented) lands on the max-tokens path: a step cut off with usage but empty content (e.g. only a dropped tool call) previously emitted a standalone `usage`. It now records an empty-content `assistant/message { content: [], usage }`. To keep that from injecting a spurious content-less assistant turn into the provider transcript, `deriveMessages()` skips empty-content `assistant/message` events. A regression test asserts usage stays represented AND derived history is uncorrupted. -**Format version.** The persisted `SessionEventMap` shape changed (usage folded onto `assistant/message`, standalone `usage`/`error` removed, `step` on `turn/end.reason.error`). The session log uses the **pinned-`0` "unstable / pre-release"** format stance (one of the two stances AGENTS.md § pre-release sanctions): `SESSION_FORMAT_VERSION` stays `0` and absorbs this and every other pre-release shape change without a monotonic bump — bumping on each tweak would dress up an unstable format as a sequence of stable boundaries that mean nothing yet. The constant is centralized in `dsh-session` and read by both write sites and the coordinator's load-time check, which rejects any non-`0` log (no migration — there is no persisted user data to preserve; a real monotonic policy begins at the first tagged release). `turn/end.reason.error.step` is required for newly-written logs. +**Format version.** This changes persisted events, but the pre-release session format remains pinned at `0` and rejects any other version without migration. `dsh-session` owns the constant used by writers and load validation. Monotonic format versions begin at the first release. Usage is now observed on `assistant/message.usage`; an operational error's step on `turn/end.reason` for `kind: 'error'`. `agent/error` + logging are unchanged for live diagnostics. 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 585c611262..2b10bf36db 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 @@ -18,7 +18,7 @@ This is the [drop-mutable-session-summary](../../implemented/simplification/2026 ## Decision -`stream()` is the only public LLM call surface. Removed with their JSDoc and doc references: `LlmService.streamBlocks()`; `LlmService.generate()`, the `llm/generate` waterfall event, and `GenerateResult`; `BlockAssembler.flushReady()`/`flushRemaining()` and the `flushed` cursor field; and `BlockAssembler.result()`, which only served the deleted `generate()` path. Adapter tests drive `ctx.llm.stream()` through a small helper that pushes chunks into `BlockAssembler` and returns the assembled message, usage, and finish reason — keeping the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) intact without a public method whose only callers are tests. The assembler invariants that apply to `push()` / `blocks()` / `message()` keep their tests; the flush-API pins went with the API. The `ctx.llm` service-map row in [docs/architecture.md](../../../architecture.md) is `stream()` only, the event taxonomy carries no `llm/generate`, and the [property-based-testing RFC](../../implemented/testing/2026-06-11-property-based-testing.md) names block-assembly invariants without the removed convenience methods. +`stream()` is the sole public LLM call surface. Remove `streamBlocks`, `generate`, its event/result types, and assembler helpers used only by that path. Adapter tests assemble the public stream through a local helper, while `BlockAssembler` retains only the operations with production consumers. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md index 765cadd573..ce16ff2ee2 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md +++ b/docs/rfc/implemented/simplification/2026-06-20-prune-dead-seam-methods.md @@ -2,7 +2,7 @@ Status: implemented -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed pruning dead methods from BOTH the persistence seam (`SessionPersistence.has()`/`.delete()`) and the bash seam (`BashExecutor.get()`/`.list()`). Only the **persistence** removal shipped. The bash `get()`/`.list()` removal was reverted before merge: each is a one-line accessor over the executor's already-tracked `tasks` map, and removing them forced `dsh-tool-bash`'s tests onto a ~35-line `onTaskDone`-based completion-tracking harness to replace the one-line `ctx.bash.get(id)` lookup — the migration cost dwarfed the surface removed. Per the [AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md) principle, that friction is evidence the method earns its keep (a test harness IS a consumer that programs against the seam), so `get()`/`list()` stay. The bash-seam analysis below is retained for the record but was NOT acted on; `BashTaskId`-branding those methods lands in the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md) instead. The persistence removal stands: `has()`/`delete()` had only contract-test callers and no test-ergonomics cost to remove. +> **Implementation note:** Only `SessionPersistence.has()` and `.delete()` were removed. `BashExecutor.get()` and `.list()` remain because removing their one-line lookup surface required substantially more completion-tracking machinery in consumers. Their id branding is covered by the [branded-ids RFC](../architecture/2026-06-20-branded-ids.md). ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md index b97d0abcee..7ed7d10211 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -2,7 +2,7 @@ Status: implemented -> **Implementation note (scope narrowed from the original proposal).** This RFC proposed removing BOTH `abort()` and `whenIdle()` from the public `Agent` handle. Only `abort()` was removed. Validating the premise against the code ([AGENTS.md "RFCs are proposals, not golden truth"](../../../../AGENTS.md)) found `whenIdle()` to be a **load-bearing quiescence primitive**, not dead surface: it is the settle signal in several ACP tests (`packages/ui/acp/tests/{edges,turns,dispose}.spec.ts`) and is backed by a deliberate loop contract (settle waiters without a status transition; handle the replacement-turn race). The RFC's suggested migration — have consumers observe the `running`→`idle` transition by hand — is exactly the brittle hand-rolled path [the defensive patterns](../../../defensive-patterns.md) warns against ("Async state is not synchronous state"). Deleting a clean primitive to push every consumer onto that is a net loss, so `whenIdle()` stays. `abort()` was genuinely dead public surface (no production caller; the loop aborts its own `AbortController` directly), so it was removed as proposed. The text below is amended to describe what shipped. +> **Implementation note:** Only `abort()` was removed. `whenIdle()` remains because it is the public quiescence signal and safely handles waiter settlement and replacement-turn races; consumers should not reconstruct that behavior from status transitions. ## Problem diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index dbef22f3a3..b38c80b710 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -2,18 +2,9 @@ Status: implemented - - ## Problem -The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`. +The loop exposed durable turn and step boundaries through both `SessionEvent` and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. The stdio UI was the only remaining mirror consumer; ACP and persistence already used the session log. This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. @@ -21,24 +12,15 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. - -The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. +Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that also needs an agent id maintains a session-to-agent map from `agent/created` and `agent/disposed`. ## Scope: what is and isn't removed -Removed (durable-boundary mirrors — the session log is authoritative for each): `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`. - -RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: - -- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). -- `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). -- `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. +This decision covers only durable turn and step boundaries. Steering and stream mirrors have separate decisions: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors. ## Alternatives considered -- **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). -- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` + the id map instead. +- **Keep turn mirrors for the stdio UI** — rejected because the UI can render `session/event` and recover the agent label from its id map. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md index 0e999c629f..017eb952eb 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -8,20 +8,16 @@ Status: implemented ## Decision -Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. +Remove `ImageBlock`, its map entry, and image-specific branches from adapters, ACP rendering, and compaction. Update the owning vocabulary docs and generated references in the same change. Unknown extension blocks still exercise default branches, and ACP continues to reject inbound image prompt content independently of the harness vocabulary. ## Alternatives considered ### Why not keep it? -This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. +`ContentBlockMap` can reintroduce images when adapters, ACP, and compaction all support them. Keeping a core type whose only implementation is rejection would advertise an unusable surface; absence gives producers an immediate compile-time failure instead. The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. -## Verification - -No `ImageBlock` / harness `type: 'image'` block is constructed anywhere outside RFC records; the codec's inbound ACP-image rejection keeps its tests; and the adapter/codec/compaction switches handle the case through their unknown-block default arms, pinned by the plugin-added-block tests. - ## Consequences Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index b6fbded6ac..35868be2fd 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -15,7 +15,7 @@ This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/ ## Decision -The event declaration, both emits, and the rollback-before-emit ordering are deleted (the plain `ctx.effect` disposer carries HMR cleanup). `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` are deleted — the provider-private `status()` stays, since it feeds execution-time selection. The listener-throw rollback test that existed solely for the removed event is gone, and the emission assertions and every status-based assertion are rewritten onto the behavior a real caller observes: a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets. The cordis catalog is regenerated; `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md) describe the shipped contract; the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) are amended per [implemented/AGENTS.md](../AGENTS.md). +Remove the registry-change event, aggregated status methods and type, and their dedicated tests. Provider-private status remains for execution-time selection. Caller-facing coverage now asserts successful execution or structured selection errors, and the owning web docs describe that on-call contract. ## Alternatives considered diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 38ec9ee4fa..5205a5872e 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the app's export SHAPE is pinned by the stdio-agent unit suite's explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam and tests. It retains the named Cordis plugin export shape consumed by the app, while keyless Loader smokes cover the composed entry path. The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md index 4c76d7f044..a0a383b255 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 @@ -6,7 +6,7 @@ Status: implemented `agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. -Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC makes. The [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) kept it as a live control signal rather than a boundary; the [stream-chunk removal](2026-07-02-remove-stream-chunk-mirror.md) retained it on the reading that it had no durable twin. The second rationale did not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fired at the exact moment its durable twin landed, carrying nothing the log does not. +`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. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md index 9f30dc67f8..79abfab2a0 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes. +The stdio and ACP bins duplicated environment loading, fail-loud handling, entry validation, and boot logic, including subtle Loader failure behavior. Their copies had already drifted and lived in self-executing files excluded from unit coverage, making their helper exports unusable. ## 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 14d931a8f3..eabc910f92 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 @@ -19,11 +19,7 @@ Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the ### Why not keep them? -The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. - -## Verification - -`HookDialect` is two-valued (`rg "'native'"` in the hooks packages returns nothing); `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer, while `durationMs` stays on `hook/result` and in the fixtures with the replay scrub intact; the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`, with per-hook `timeoutSec` still overriding; and the truncation rule and decision-string rule are defined once, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. +Unsupported vocabulary can return when a real consumer exists. `durationMs` remains because durable audit timing is useful independently of a current reader. Bridge-specific payload construction stays in each bridge, while shared durable-event normalization belongs in the protocol library. ## Consequences 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 13c3635477..db5910665c 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 @@ -11,13 +11,13 @@ Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: ## Decision -`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for. +Hardcode the existing handshake identity at initialization and remove the unreachable config fields and duplicate defaults. Replace `toolKindFor` with neutral `'other'` at both presenter fallbacks. Normal first-party presentations are unchanged; malformed or failed presentations now render an honest generic card instead of inferring a kind from the tool name. ## Alternatives considered ### Why not keep them? -`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call. +Branding can return when the app package exposes it to deployments. Inferring presentation from unknown tool names violates the render-intent contract; neutral fallback cards also preserve raw input for malformed calls and broken presenters. ## Consequences 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 d809376115..c543a147bb 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 @@ -4,7 +4,7 @@ Status: implemented ## Problem -The harness has two test tiers: keyless unit `.spec.ts` (the 100%-per-file coverage gate) and real-API `.e2e.ts` (key-gated, self-skipping in CI). Neither continuously verifies the **complete output transcript** an ACP editor (Zed) sees on its stdin/stdout. The existing ACP e2e ([examples/acp-agent/tests/acp.e2e.ts](../../../../examples/acp-agent/tests/acp.e2e.ts)) is the closest end-to-end check, but it is key-gated and asserts on a handful of *structured fields* (`stopReason`, a `tool_call` title), not the byte-for-byte stream of `session/update` frames. That leaves the "green units, broken product" gap: every unit test can pass while the actual editor-facing protocol output regresses — the same class of failure that shipped the inject bug ([docs/postmortem/0001](../../../postmortem/0001-acp-default-export-drops-inject.md)), where 178 hand-mounted tests stayed green while a real Zed session crashed instantly. +Unit tests do not exercise the complete ACP subprocess transcript, while real-API tests are nondeterministic and key-gated. Editor-facing `session/update` output can therefore regress despite green unit coverage, as the [default-export postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) demonstrated. The blocker for a full-transcript test is the model: the agent's output is driven by a non-deterministic LLM, and a key-gated test that hits the real API on every run is neither deterministic nor CI-runnable. We want the fidelity of a real run with the determinism of a fixture. @@ -12,17 +12,15 @@ This RFC records the decision to add a third test tier — **snapshot tests** ## Decision -A snapshot test boots the **real** `examples/acp-agent` subprocess, drives it over real ACP stdio with a deterministic input script, and diffs its (normalized) output against committed golden files. The model is made deterministic by **recording a real run's session log once** against the real API and **replaying it** on every subsequent run. The committed fixture IS the persisted session JSONL — the same append-only log the harness writes for any session. +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. ### The fixture is the persisted session JSONL -The per-scenario fixture is `/session.jsonl`: the exact log produced by running the scenario once against the real API (the snapshot harness harvests the file the JSONL persistence backend writes). This log already contains everything needed to reproduce the run deterministically: its `assistant/chunk` events carry every parsed `StreamChunk` (the LLM's behavior), and its `tool/call`/`tool/result`/`turn/*`/`assistant/message` events carry the harness's behavior (token usage rides on `assistant/message.usage`). One artifact captures both, and it is the format the codebase already treats as the authoritative replay record ([packages/core/session/src/types.ts](../../../../packages/core/session/src/types.ts): "raw chunks are the replay record"). - -An earlier draft used a hand-authored `llm.json` of model chunks; reusing the real session log instead means the fixture is a genuine product of the system (not a hand-built mock), and it doubles as a behavioral golden (see below). A byte-level HTTP-record library (Polly/nock/MSW) was rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. +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. ### Replay derives the model script from the log -The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/llm/src/index.ts](../../../../packages/llm/llm/src/index.ts)) — a single listener intercepts every model call regardless of adapter (deepseek, pi-ai), because the loop routes all model calls through `ctx.llm.stream()`. The `llm-replay` plugin short-circuits that waterfall (never calls `next()`) and serves back streams reconstructed from the log: `deriveReplayScript(events)` groups `assistant/chunk` events by `(turn, step)` in log order, yielding one model stream per group. This grouping is exact because the agent loop makes **exactly one `ctx.llm.stream()` call per step** and tags every chunk with the current `(turn, step)` ([packages/core/agent-loop/src/loop.ts](../../../../packages/core/agent-loop/src/loop.ts)): `step` increments once per loop iteration, so `(turn, step)` is unique per model call. A `finish {kind:'error'}` chunk is part of its group and replays naturally — no special-casing. +`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling. ### The in-memory replay entry honors the full LLM contract @@ -34,37 +32,36 @@ The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/l | { kind: 'hang' } ``` -`chunks` is what the log derives. The other two cover the LLM contract's failure branches the log **cannot** reconstruct from `assistant/chunk` alone: a *pure throw before any chunk* (e.g. an HTTP 401 — the log holds only a `turn/end {error}`, no chunks) and a *cancel/hang* (a timing behavior, not chunk content). A scenario needing those supplies an optional `/replay.override.json` (a `ReplayEntry[]`) that **replaces** the derived script. The `throw` entry carries any prefix chunks so a mid-stream failure replays its partial output before throwing — the "honor cross-seam contracts on BOTH sides" defensive pattern. Synthesizing throw/cancel from the log's `turn/end {kind:error|aborted}` was rejected: it would couple `llm-replay` to loop-internal turn-closing semantics and the `turn/end` reason is lossy (it can't distinguish a thrown 401 from a finish-error). An explicit sidecar is the cleaner seam. +Logs derive chunk entries. Pre-stream throws and hangs have no reconstructable chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons. ### Positional replay, one in-flight stream -Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This is deterministic **only when at most one model stream is in flight at a time**. The first cut runs one ACP session per scenario, which guarantees that. Multi-session concurrency (the bridge multiplexes N sessions, which can prompt concurrently) would let scheduling decide which model call consumes which entry — so concurrent-session snapshots are out of scope until entries are keyed by request rather than position. A scenario whose control flow changes the number/order of model calls must be re-recorded; the cursor **fails loud on overrun** rather than silently reusing or skipping an entry. A missing `session.jsonl` in replay fails loud too ("record first") — never a silent skip. +Replay is positional and therefore permits only one in-flight model stream per scenario. Concurrent-session snapshots require request-keyed entries. Changed call order requires re-recording, and missing or exhausted fixtures fail loudly. ### Recording harvests the log; keyless replay needs a providerless config Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot boot the normal config as-is — `examples/acp-agent/cordis.snapshot.yml` is an include-overlay of `cordis.yml` that disables the `llm-deepseek` entry by id and inserts `llm-replay` (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)); every other entry IS the live tree, loaded through the include. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. +Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with `llm-replay` while retaining the live composition. Recording uses the ordinary config and a harness-supplied persistence root. Replay mode skips `.env` loading, so a stray API key cannot trigger a live call. See the [single-source config RFC](2026-07-04-single-source-acp-replay-config.md). ### Two surfaces: normalize, then compare 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`. -2. The **re-persisted session JSONL** — the log the replay run itself persists, compared against the scenario's `session.jsonl`. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. There is no separate session golden: `session.jsonl` is both the replay source (recorded scenarios) and the expected produced log. Both sides pass through `normalizeSessionLog` before comparing — the fixture is raw-harvested (its own real session id / cwd / timestamps) and the replay output has fresh ones, so each is scrubbed against its own volatile values (the fixture's read from its header line) and the comparison is on normalized form. Every stored JSONL additionally scrubs composed prompt text to `{{system}}`; each header class's pinning scenario stores that prompt readably in `system-prompt.golden.md` and keeps the complete tool schemas in its JSONL, while other scenarios scrub schemas to `{{tools}}` ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)). For an authored override scenario the same `session.jsonl` holds the expected produced log; `replay.override.json` drives the model, and `llm-replay` ignores the fixture for model chunks when an override exists, so committing the expected log there does not affect replay. +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 two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `assistant/message.usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea. +The surfaces are complementary: stdout covers bridge projection, while JSONL covers loop, tool, and boundary structure that the projection omits. -Both surfaces contain non-deterministic values that a pure normalization function scrubs **before** the compare: `randomUUID()` session ids → `{{sessionId}}`, the temp `mkdtemp` cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header), JSON-RPC ids → a stable sequence, and the log's per-event `time` (epoch ms) + header `createdAt` dropped or zeroed (the log's `seq` is left intact — it is deterministic by contract, `seq = log.length`). Real bash runs during replay, so the JSONL normalizer additionally stabilizes tool-output volatility (any embedded paths/pids/timestamps) — scenarios keep bash commands tightly constrained (`echo`, file writes; no `date`/`env`/background/large-output) so this surface is small. The committed `stdout.golden.jsonl` is itself **JSONL** — one compact, normalized record per line, in the same shape as the wire (NDJSON on the wire, JSONL on disk), so it stays `grep`/`jq`-able and faithful to what the agent actually emits. A separate raw-purity assertion keeps the guarantee that every stdout line parses as JSON (no logger leak onto the protocol channel). Vitest's `toMatchFileSnapshot` provides the stdout golden store and the `-u`/`--update` "accept the diff" workflow; the session log is checked with a plain normalized-string equality against `session.jsonl`, NOT `toMatchFileSnapshot` (which would overwrite the 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 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. ### Isolation: normalization now, sandbox later - -Determinism of the tool environment comes from a per-test `mkdtemp` cwd, the executor's existing secret-scrubbing env (`/KEY|SECRET|TOKEN/i`), the fresh non-login `bash -c` per call, and the normalization pass — **not** from an OS sandbox. A real rootless sandbox (bwrap on Linux, sandbox-exec/Seatbelt on macOS) is the established cross-platform pattern (Claude Code, Codex), but it is per-OS, fragile on newer kernels (Ubuntu 24.04+ AppArmor blocks unprivileged user namespaces), and unnecessary for transcript determinism. It is reserved as a future tier via the documented `BashExecutor` capability seam ([a sandboxing executor replaces dsh-bash-local without touching a tool schema](../architecture/2026-06-13-capability-seams.md)) — a new `bash-*` package, not a change here. Scenarios keep bash commands tightly constrained (no `date`/`env`/background/large-output) so the temp-dir tier suffices. +Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-login shell, constrained commands, and normalization. It does not claim OS confinement. A sandboxed executor can replace the local backend through the existing [capability seam](../architecture/2026-06-13-capability-seams.md) if a stronger tier is needed. ### The replay plugin is its own package -The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`packages/support/llm-replay/`), and the snapshot config references it by package name. It is the keyless replacement for the real LLM adapter: it installs an `llm/stream` waterfall listener and short-circuits it, serving model streams reconstructed from a recorded session JSONL. Its sole consumer is the ACP snapshot harness here, but it is a package (not example-local glue like echo-agent's [mock-llm.ts](../../../../examples/echo-agent/src/mock-llm.ts)) so that its derive/parse/replay branches fall under the per-file 100% coverage gate on package `src` trees — logic under `examples/` is not measured by that gate, which would leave those branches unguarded. +`@deepseek-ai/dsh-llm-replay` is a support package rather than example-local glue. It replaces the real adapter by short-circuiting `llm/stream` with streams reconstructed from JSONL, and its package placement keeps the replay logic under normal coverage gates. ### Two subcommands, replay in the default gate @@ -78,6 +75,6 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack ## Consequences -A new test tier and its fixtures to maintain: each scenario is a directory of `input.json` (the client stdin script) + `session.jsonl` (the recorded log, which doubles as the expected re-persisted log) + an optional `replay.override.json` + an optional `workspace/` seed dir + the `stdout.golden.jsonl`, committed and reviewed. A scenario that needs the agent to operate on existing files (read, edit, grep) ships a `/workspace/` directory; the harness copies its contents into the temp cwd before the run, so the seeded files are present for both record and replay (the cwd is normalized in the goldens, so the seeded paths stay stable). Re-recording when the model's phrasing changes churns the fixture and the stdout golden — visible in review, which is the point of committing them. Bought: deterministic, keyless, full-transcript regression coverage that boots the real Loader (so it still guards the export-shape bug class), exercises the real bash executor, and gives a one-command accept-the-diff loop. The tier is ACP-first but the harness (subprocess + tee + input-DSL + workspace seeding + normalization + JSONL-derived replay) is example-agnostic and extends to other examples. +The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. In return it provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. This RFC relates to but does not supersede the [proposed determinism RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index e567c4ff24..24b5050628 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS ### Scope, runtime shape -Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the primary line): these tests exercise API integration, not node-version compatibility, which ci.yml's Node 22.19/24/26 matrix owns. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `14`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled. +The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal. ## Security diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 2a6f8b7ae3..45348cfd2e 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -32,4 +32,4 @@ Reviewers lose one artifact name that made the expected persisted log visually s ## Implementation note -The comparison normalizes BOTH sides, but each against its OWN volatile values, not a shared context. A raw harvested `session.jsonl` bakes in the recording run's session id, cwd, and timestamps; the replay run produces fresh ones. `normalizeSessionLog` scrubs cwd by exact string match, so normalizing the fixture against the *replay* run's cwd would leave the recorded cwd in the header unscrubbed and the compare would fail. The harness therefore derives the fixture's normalize context from its OWN header line (`{ type:'session', id, cwd }`) — `fixtureContext()` in `dsh-acp-snapshot`'s suite module — so both sides scrub to the same `{{sessionId}}`/`{{cwd}}` tokens. An authored fixture copied from the old golden already carries the normalized header (`id:'{{sessionId}}'`, `cwd:'{{cwd}}'`), which yields those tokens as the volatile values and scrubs idempotently. The session-log side uses a plain normalized-string `toEqual`, NOT `toMatchFileSnapshot`, so a run never overwrites the fixture. +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-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md index a60d487e91..14db415b1b 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -27,7 +27,7 @@ Record where a session's **inherited** prefix ends, persist it, and have the rep - **JSONL**: a `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`). - **SQLite**: a `seed_length` column on the `sessions` table. -The SQLite change is a breaking table-layout change, so `SCHEMA_VERSION` bumps. This branch added `seed_length` under version **3**; it later merged with the session-surface branch, which had independently shipped its OWN version-3 layout (the `source_event_seqs`/`surface_op` columns). Because an on-disk `3` is ambiguous between the two sibling layouts, the merged build is version **4** (every column), and an on-disk `3` is rejected like any other non-current version. Per the repo's pre-release stance (§ "Pre-release stance" in AGENTS.md) the backend **rejects** a non-current `user_version` on open rather than migrating it — there is no persisted user data to preserve, so no migration code is written (the existing reject-not-migrate path at `openDatabase` already enforces this; v1, v2, and the collided v3 are all rejected). +The SQLite layout containing `seed_length`, `source_event_seqs`, and `surface_op` is schema version 4. Earlier version 3 layouts were ambiguous, so every non-current `user_version` is rejected without migration under the pre-release policy. ### 3. Replay derives a child script after the boundary 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 d114b25176..fd2d13c84f 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 @@ -29,7 +29,7 @@ Live session ids are freshly random every run and never equal the recorded ones, This keys by WHO calls, not by global call order — so it stays correct even if subagents ever run concurrently or in the background (a global cursor would interleave them). A call carrying no `sessionId` (a direct unit-test `stream()`) is treated as one anonymous session bound to the primary script, so the single-session path is byte-for-byte the old behavior. More distinct live sessions than recorded scripts is a fail-loud error (an unrecorded subagent appeared), never a silent mis-route. -The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site. +Child fixtures sort by `createdAt`, which matches call order while siblings run strictly sequentially. The id tiebreak only makes degenerate collisions deterministic. Concurrent or background children must introduce an explicit first-call ordinal instead of relying on timestamps. ## Alternatives considered 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 358aa64147..70730f0382 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 @@ -8,7 +8,7 @@ Status: implemented ## Decision -`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry (matched by id AND asserted by `name`, so a reused id can never disable the wrong plugin) and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with an optional name assertion, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). +`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. 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 2f631a5d8a..436b4c1ce6 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 @@ -6,13 +6,13 @@ Status: implemented 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). -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 already triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness (`TODO(acp-test-harness)`). Location also 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. +A second ACP example could only copy record, normalization, and harvest logic that must stay consistent. Code under `examples/` also sat outside the package coverage gate, and the original harness could only cancel permission requests. The shared package makes the machinery measured and lets scenarios script approval answers. ## Decision The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) (`@deepseek-ai/dsh-acp-snapshot`); an example's `*.snapshot.ts` is its scenario table, its agent paths, and one factory call, over its own `snapshots/` fixtures and `cordis.snapshot.yml` overlay ([single-source replay config](2026-07-04-single-source-acp-replay-config.md)). Reading `DSH_SNAPSHOT` stays at that edge — the library takes a resolved `mode`. -**`src/harness.ts`** — `runScenario` and the input-script/result types, parameterized by an `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`; absolute paths the consuming suite resolves from its own `import.meta.url`). The client's `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. +**`src/harness.ts`** provides `runScenario` and its script/result types, parameterized by the agent's bin and config paths. Permission answers form a FIFO queue keyed by stable option kind rather than random option id. Missing answers cancel the request; an unavailable kind cancels the agent request and fails the scenario. **`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. @@ -27,10 +27,6 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su - **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. - **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 - -Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the REAL spawn path by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` covers every step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), env forwarding, workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. Two structurally unreachable guards carry reasoned `v8 ignore` comments. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). - ## Consequences A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 3de3c7d9be..5d8601a1e1 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine. +Add isolated subagent providers for Claude Code and Codex. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. ## Proposal @@ -14,7 +14,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. - `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, and a non-rejecting `result` that maps child failures to stop reasons while logging the original error. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. ## Verified interface facts (pinned versions) @@ -31,11 +31,11 @@ Both integration surfaces were verified against pinned implementations before th ## Isolation and credentials -Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`. +Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary environment variables, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start`. ## Permission and approval policy -Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP. +Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with rejected fallback permissions; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Every server request is answered programmatically, including unknown methods, so a child cannot wait indefinitely for unavailable human input. ## StopReason mapping @@ -45,11 +45,11 @@ Liveness posture, stated explicitly: teardown timing is config, turn duration is ## Testing -Named at every tier per the root AGENTS.md rule, and de-risked up front: +Coverage is required at each applicable tier: -- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape. -- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy. -- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile. +- **Keyless unit/integration:** use scripted child processes through the real SDK or wire client to cover round trips, stop mapping, cancellation, permissions, isolation, failures, and cleanup. +- **With-key e2e:** each real engine performs file work under an explicitly writable policy; skips name the missing binary or key. +- **Snapshot:** deferred under provider-specific TODOs pending the per-session replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). ## Alternatives considered diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 3587393efe..c4ee6161bd 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). The ACP snapshot suite's scenario table (`examples/acp-agent/tests/acp.snapshot.ts`) hand-maintains a `childSessions` count per scenario that duplicates the number of `session..jsonl` fixture siblings on disk. These lists are small today, but every new package or scenario class creates another manual synchronization point. +Package and gate inventories are repeated across TypeScript project references, package docs, CI prose, Knip overrides, and snapshot scenario metadata. Most restate package layout, manifest data, aggregate command contents, or fixture files. Each new package or scenario therefore creates avoidable synchronization points. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 19dee0ed56..f7af722663 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -15,7 +15,7 @@ The agent factory carries TWO ids for what is, in every live consumer, one thing - **Resume**: a caller-supplied `agentId` (e.g. `"main"`) on a persisted `resumeSessionId`. - **In-process subagent children**: the backend mints the child's `agentId` and `sessionId` as two independent UUIDs (`packages/subagent/subagent-inprocess/src/index.ts`) that nothing distinguishes — `parentSession` records lineage independently. -Where a live consumer looks an agent up, no lookup needs an id translation: the ACP bridge — the primary production path — already unifies the two (`agentId === sessionId === `; both factory call sites brand `AgentId(sessionId)` directly, and its reverse lookup keys on the `Agent` object itself), and the CC hooks bridge resolves subagent children directly by the `agentId` its lifecycle event carries. The one production population whose two ids actually DIVERGE is the in-process subagent children — the same cosmetic separation as the config path, and the same one-field simplification under unification. One consumer already pays the two-id tax: ui-stdio keeps a `labelBySession` map (seeded from the registry, maintained by `agent/created`/`agent/disposed` listeners) solely to translate `session.header.id` back to an agent id for its turn labels — machinery that deletes outright when the ids unify. And the CC hooks bridge stamps `session_id: agent.session.header.id` into every hook payload, so under unification a subagent hook's `session_id` and `agent_id` become the same string — one less identity for a hook author to reconcile. +Live consumers need no id translation. ACP already uses the session id as the agent id, hooks resolve children directly, and only config-created or in-process agents mint cosmetic differences. Stdio maintains a reverse map solely for labels; unification removes it and gives hooks one identity to report. The separation is **latent generality no consumer exercises**: nothing reads a *stable* `agentId` back across runs (each process starts fresh, and persistence keys off the session id, never the agent id). The config path's "stable agentId, fresh sessionId" buys nothing concrete — it is cosmetic. And the `agentId !== sessionId` case is precisely what opens the bash owner-token alias hole: the bash completion-notice routes by `session.header.id`, but the registry enforces uniqueness only on `agentId`, so a programmatic caller registering two agents with different agent ids but the SAME session id can mis-route a notice (see [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) § Seam precondition). The current code documents this as a precondition rather than guaranteeing it. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index c6bfbe967c..e28c5b96c0 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -12,7 +12,7 @@ Three pieces of public spine surface share one defect class: their only possible ## Proposal -Delete the method and its test; delete the three export lines and their `packages/core/agent-loop/README.md` rows, pointing the inbox spec at the source module; drop the result field from the type, the registry's construction sites (deny, dispatch, `toolErrorResult`, post-execute snapshots), its around-wrapper mismatch validation, the loop's ignore-comment, and the tests that prove the duplicate id cannot matter. The result's consumed `additionalContext` ferry and the execution object's authoritative `callId` stay untouched. Update the `ToolExecutionResult` paste in [tools.md](../../../core-data-structures/tools.md) (and its `scripts/type-equiv.manifest.json` row) and the result-shape row in `packages/core/tools/README.md`; for the `invalidate()` removal, amend the [session-surface RFC](../../implemented/architecture/2026-06-18-session-surface.md)'s full-rebuild-after-wholesale-replacement sentence per [implemented/AGENTS.md](../../implemented/AGENTS.md). +Delete the dead method, exports, duplicate result id, construction and validation branches, and tests that exist only for them. Keep `ToolExecution.callId` and result `additionalContext`. Update the owning tools reference and session-surface record with the changed public shapes. Sequencing: the surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal can land after or alongside it mechanically. The full execution pipeline carries the immutable execution object through pre-policy, guards, around-dispatch wrappers, post-policy, and final result observation; nothing needs the result to repeat its id. diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 1af873731c..4c52d0b1a4 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -15,7 +15,7 @@ The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the mock's structured branch and its `capabilities`/`structured` config knobs, and the tests that exist to pin the removed surface (the two rejection rows, the spawn absence test, the mock structured specs). Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, and the README rows in `packages/subagent/subagent`, `packages/subagent/subagent-spawn`, `packages/subagent/subagent-fork`, and `packages/support/subagent-mock`. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). -**Keep** `depthLimit`/`maxDepth` and the capability-check mechanism itself — with eyes open about its current reach. The in-process backend genuinely enforces the cap (`SubagentDepthError` in `packages/subagent/subagent-inprocess/src/index.ts`), but no production request sets `maxDepth` (`tool-subagent` exposes no knob for it), so on the shipped tool path the guard is dormant and recursion is uncapped. The alternative — remove the depth machinery too, on the argument that a dormant guard reads like a safety property while providing none — was considered and rejected: recursion is the seam RFC's named risk, the enforcement is real working code rather than vocabulary awaiting an implementation, and the honest completion is wiring a default cap through `tool-subagent` (a few-line feature) rather than deleting the only existing guard. One live capability row also keeps the two-tier design demonstrated rather than merely remembered. +**Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. Adjacent surface examined and deliberately left alone: `SubagentService.getProvider()`/`list()` have test-harness consumers only, but the [prune-dead-seam-methods implementation note](../../implemented/simplification/2026-06-20-prune-dead-seam-methods.md) records precisely this shape being removed from the bash executor and reverted — a test harness IS a consumer for a one-line accessor over an already-tracked map. `SubagentRunEndInfo.lastAssistantMessage` is a recorded keep (the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md)'s review dropped `agentType` and kept it deliberately, as the only final-message channel for out-of-process children); its currently-unwired bridge forwarding is a gap to close or a consumer to document, not surface for this RFC to cut. diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 6f5c8890bf..e789101e3b 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -48,6 +48,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution's opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call's `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency). +Filesystem read-before-edit policy stays on `fs/*` events. Generic pre/post waterfalls host hook and approval policy, `ctx.approval` resolves asks before guards, and `tools/execute` hosts around-dispatch concerns such as timeouts. `tools/result` observes the immutable final outcome. Code Mode sends both `run_code` and its serialized sub-calls through this pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index 210b316b65..f286277dea 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -15,14 +15,6 @@ Mock-only examples need only the keyless tier; state the exception in the test. A keyless smoke launched from a temporary cwd sets `TSX_TSCONFIG_PATH` to the root tsconfig and passes `--expose-internals` when loading HMR. -## Current state - -| Example | Keyless smoke | With-key smoke | -|---|---|---| -| `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) | -| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit; `tests/code-mode-keyless-smoke.e2e.ts` — the same boot guard for the Code Mode overlay | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified; `tests/code-mode.e2e.ts` — a real model composes two bash calls in one `run_code` program; collapsed header, dispatch events, written file all verified | -| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject | -| `sandbox-acp-agent` | `escalation.e2e.ts` — boots the real tree (sandbox + approval + bridge) keyless: initialize + `session/new` | same file — denied → escalates → a scripted client grants (the write must land) or rejects (it must not); skips without key/runner | -| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written | +Do not maintain a prose inventory of example tests here; the `tests/` trees and root scripts are authoritative. See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 7f711cb297..3a890b940b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,7 +19,7 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. -Its `code-mode.cordis.yml` overlay flips the same tree to **Code Mode**: the worker-thread code runtime is loaded and the tool registry runs `mode: code`, so its registry contribution is the reserved `run_code` transport plus a generated TypeScript SDK section, and the model composes the other tools by writing a program whose output it curates. Run with: `pnpm run demo:code-mode` (the REPL is the default UI; `acp` as the argument serves the acp-agent example's same-shaped overlay instead) — see the [Code Mode section](coding-agent/README.md#code-mode) for what to try. +Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. ## cordis-agent diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index deebd154bb..fff5a48ee0 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # the same server in Code Mode: one wire tool, run_code ``` -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC. `demo:code-mode acp` boots the same tree through the [`code-mode.cordis.yml`](code-mode.cordis.yml) overlay — the tool surface collapses to `run_code` + the generated TypeScript SDK, dispatching through the worker-thread code runtime (see the [dsh-tools Code Mode section](../../packages/core/tools/README.md#code-mode)). +The leaf config loads the ACP app, swappable backends, model-facing tools, and repeat guard. The app bundles the agent spine, JSONL persistence, and bridge, creates agents on `session/new`, and keeps stdout logger-free. [`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with `run_code`; see [Code Mode](../../packages/core/tools/README.md#code-mode). ## stdout is the protocol diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 5a607d3c7b..5d6713144c 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -33,7 +33,7 @@ The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_ ## Code Mode -[`code-mode.cordis.yml`](code-mode.cordis.yml) is this same tree flipped to [Code Mode](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): an include overlay over `./cordis.yml` whose two patches insert the worker-thread code runtime (`@deepseek-ai/dsh-code-runtime-worker`, registering `ctx.codeRuntime`) and set `tools: { mode: code }` on the app. The registry contributes exactly one reserved wire transport — `run_code` — plus a generated TypeScript SDK section declaring the visible end-capability tools. The model composes those capabilities by writing a program; each program call carries an immutable link to its enclosing transport, bridges back through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation one at a time, and is logged as a `tool/code-dispatch` session event. Only what the program prints or returns re-enters model context. (Flip the mode to `both` to offer native calls and `run_code` side by side.) +[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode RFC](../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) for the execution contract. ```sh pnpm run demo:code-mode # this overlay under the REPL (default UI) diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 512688d88d..221d61b3da 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -16,14 +16,8 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' /** - * The Code Mode with-key proof (the RFC's e2e tier): a REAL model under - * `mode: 'code'`, a task that requires composing two tool calls, verified - * against the WORLD — the persisted request header carried exactly - * `[run_code]` as the wire tool list, each sub-call landed as a - * `tool/code-dispatch` event, the file the program wrote exists on disk, and - * the final answer is the program's curated output. Key-gated (see - * vitest.e2e.config.ts); the keyless Loader-path smoke of the overlay lives - * in `code-mode-keyless-smoke.e2e.ts`. + * With-key Code Mode proof: a real model composes tool calls, writes a file, and + * returns curated output while the log records `run_code` and its sub-dispatches. */ const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 4b7bed4d1c..3908ad5550 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -2,15 +2,14 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific. -- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). -- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md). +- **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.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)). - **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../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. - **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. Naming notes: -- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (the export-shape rule above). - `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; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 1ac8a7d06b..ceaf6ff699 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -22,7 +22,7 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. - **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +- **Model-friendly environment** — ambient credential-shaped variables are removed before noninteractive terminal defaults and explicit caller entries are applied. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. Trusted plugins use `env` and `stdin`, but the model-facing tool does not expose them. See the [bash stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. ## Sandboxing diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 2a5ae63b8e..25782f6bd5 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -14,7 +14,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker. -- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt. +- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/). diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 8ae25a8d39..7aac7086bf 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,16 +9,8 @@ import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sand import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** - * KEYLESS consumer-integration proof on macOS: the REAL `LocalSandboxProvider` - * (Linux rungs forced off, so `sandbox-exec`/Seatbelt confines) underneath - * the REAL `SandboxBashExecutor`, driven through the executor's public - * run/start paths. Verifies the WORLD (files exist or don't) plus the - * stamped result facts — in particular that Seatbelt's EPERM denial text - * classifies as `denied: true` through the wrap-carried dialect; the - * backend-only confinement proofs live with `@deepseek-ai/dsh-sandbox-local`. - * - * Self-skips wherever the functional probe fails — every non-macOS host, or - * a macOS whose `sandbox-exec` refuses the profile. + * Keyless macOS integration of the real Seatbelt provider and sandbox executor, + * including world effects and denial classification. Skips when the probe fails. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index b184714ad2..16f12069c0 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -60,12 +60,7 @@ export abstract class BashExecutor extends Service { } /** - * Resolve a caller's {@link BashExecRequest} into a fully-specified - * {@link BashExecSpec}, applying this implementation's config defaults and - * caps (working directory, default/max timeout). Consumers (tool layer) - * call this, then pass the result to {@link run}/{@link start} — keeping - * defaulting in the implementation that owns the config while the seam type - * stays explicit (no hidden `?? default` inside run/start). + * Apply implementation-owned defaults and caps to a request before execution. * @param request - the caller's request; omitted fields get this * implementation's defaults, capped fields are clamped. * @returns the fully-specified spec to hand to {@link run}/{@link start}. diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index b786be62d3..565d687745 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -71,14 +71,8 @@ export interface BashSandboxInfo { */ enforcement?: SandboxEnforcement /** - * True when the executor classifies this failure as the SANDBOX RUNNER - * itself failing (missing binary, refused profile, fail-closed refusal - * before exec) — the command NEVER RAN; this is a sandbox failure, not a - * task failure, and it outranks `denied` (a runner's own error text can - * contain denial words). Only ever stamped on settled BACKGROUND tasks: a - * foreground run surfaces the same condition as the thrown - * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error - * channel; a settled task's facts are its only channel). + * The sandbox runner failed before executing the command. Set only on settled + * background tasks; foreground runs throw `SANDBOX_UNAVAILABLE` instead. */ runnerFailed?: boolean } diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 083b0c14ca..2f54477a6c 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -22,7 +22,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. -Result text: stdout, then a `[stderr]` section, then status markers — `[sandbox: file access denied under mode]` when a sandboxing executor classified the failure as a policy denial (reported first so `[exit code: N]` stays the last line; the static description tells the model a denial is policy, not a command bug, and forbids retrying around it), `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: ]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results. +Result text contains stdout, an optional `[stderr]` section, then applicable sandbox-denial, timeout, signal, exit-code, and truncation markers. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Truncation links a safe complete spill file or reports it unavailable. Only infrastructure failures such as spawn errors and aborts produce `isError`. ### `bash_output` @@ -34,7 +34,7 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[sandbo ### Task ownership (cross-session isolation) -The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) +The executor stores the spawning session id as the task's owner. `bash_output` and `bash_kill` reject a caller with a different session id; agent-less tasks remain unowned, while agent-less calls cannot access owned tasks. Storing ownership on the task prevents predictable global ids from crossing ACP sessions and preserves the fence across tool-plugin reloads. Completion notices remain effect-scoped and may be missed during a reload gap. ## UI presentation @@ -42,11 +42,11 @@ UI presentation is tool-owned through `presentCall` and `presentResult`. Foregro ## Background completion notices -When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. +When a task finishes, the plugin resolves its owner token to a live agent and injects a durable completion notice. If the owner no longer exists, the notice is dropped. Injection affects the next request but does not wake an idle agent, so the model must poll with `bash_output` when it needs completion promptly. ## The tool builds its request from named args only -The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +The seam supports trusted-plugin `stdin` and `env`, but the model-facing tool does not. It builds requests only from its declared arguments, signal, and owner; extra model keys are ignored. Shell syntax already provides equivalent command-level behavior, while the local executor's credential scrub protects ambient secrets. See the [stdin/env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). ## Permissions and escalation @@ -56,4 +56,4 @@ Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` ap ## Per-session mode switching -Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state. +For sandboxing executors, each call resolves mode as one-shot escalation, then session override, then executor default. Non-sandboxing and agent-less calls carry no session override. The prompt does not announce the standing mode; denial results report the effective mode when the boundary matters. See the [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index a06e74b818..4a2956d0de 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -6,16 +6,15 @@ This is the implementation tier of the compaction capability — see the [interf ## What it owns -The abstract contract states only WHAT compaction does; this backend owns every HOW decision: +This backend owns the compaction policy: -- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt. -- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. -- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. -- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it. -- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event. -- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README). -- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. -- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. +- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix, derived history, and system prompt. +- **Retention** — compact the oldest whole surface units while preserving a recent tail and tool-call/result pairing. Turn boundaries do not protect old steps inside a runaway turn. An indivisible 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. +- **Summarization** — a direct `llm/stream` call uses the configured model and cap. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls. +- **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()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step so 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. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 074d516263..75b7ab0f6f 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -82,15 +82,7 @@ function createTestService(overrides: Partial = {}): TestCom return new TestCompactService(new Context(), cfg({ auto: false, ...overrides })) } -/** - * Build a multi-turn session with surface markers (simulating real agent-loop - * output). Compaction always runs inside an OPEN turn (the loop fires the - * `agent/pre-step` seam after a turn's start and before a step's start), so by - * default the session is left with a trailing open turn: turns `1..turns` - * close, then one more `turn/start` opens with no matching `turn/end`. Pass - * `{ leaveOpen: false }` for a fully-closed session (e.g. to assert that manual - * compaction is rejected when no turn is open). - */ +/** Build closed turns plus an open compaction turn unless `leaveOpen` is false. */ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { leaveOpen?: boolean } = {}): Session { const leaveOpen = opts.leaveOpen ?? true const s = new Session(SessionId('test')) diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index c4f3cf11b9..5eca77cd1e 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata ## Trust stance -The sandbox isolates the global context only — it is not a security boundary. No Node API is provided: `require`, the timers, and `fetch` are callable traps that throw a redirect to the cordis alternative (`ctx.fs` / `ctx.web` / `ctx.bash` / `inject: ['timer']` + `ctx.setTimeout`); `process` and `Buffer` are `undefined`; `globalThis` writes stay inside. These traps steer honest code onto the cordis services; they do not contain a mount that goes looking — the host-realm helpers on the sandbox global (`harness`, `console`, `btoa`) are reachable functions, so mount code can reach the host realm and Node through one of them, which is fine because `ctx` is fully privileged anyway. The `ctx` a mounted plugin's `apply` receives is a whitelist façade — register tools, observe events, provide/consume services, use timers; framework internals (`ctx.root`, `ctx.fiber`, `ctx.extend`, `ctx.plugin`, …) are withheld — but the capabilities it does expose reach the real runtime, so load this plugin as deliberately as you would grant a bash tool. +The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services, and writes to `globalThis` stay local, but host-realm helpers and the privileged context make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access. ## Config diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..a77a186fb3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -77,7 +77,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'approval', - summary: 'The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent\'s session log.', + summary: 'Approval request and policy service.', methods: [ 'async request(req: ApprovalRequest): Promise', ], @@ -143,7 +143,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionPersistence', - summary: 'Abstract durable session-persistence service.', + summary: 'Durable append-only session storage.', methods: [ 'abstract create(meta: SessionHeader): Promise', 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', @@ -187,7 +187,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'systemPrompt', - summary: 'Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step.', + summary: 'Registry service for the prompt inputs assembled before each model step.', methods: [ 'section(section: PromptSection): () => void', 'tools(provider: (context: AssembleContext) => ToolProviderResult): () => void', @@ -197,7 +197,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'tools', - summary: 'Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline.', + summary: 'Tool registry and execution pipeline.', methods: [ 'register(definition: ToolDefinition): () => void', 'restrict(filter: ToolRestriction): () => void', @@ -227,7 +227,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'workflows', - summary: 'Abstract workflow execution service.', + summary: 'Workflow execution seam.', methods: [ 'abstract start(request: WorkflowStartRequest): WorkflowRun', ], @@ -240,13 +240,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - summary: 'An agent\'s fully composed scoped world was published in the AgentRegistry.', + summary: 'A fully configured agent and its session were published.', }, { name: 'agent/disposed', mode: 'emit', signature: '\'agent/disposed\'(this: Scoped, agent: Agent): void', - summary: 'An agent was removed from the registry.', + summary: 'An agent left the registry.', }, { name: 'agent/error', @@ -258,37 +258,37 @@ export const EVENT_API: readonly EventApiEntry[] = [ 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 pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step\'s `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`.', + summary: 'Awaited checkpoint before `step/start` for outside-step surface mutations.', }, { name: 'agent/prompt-submit', mode: 'waterfall', signature: '\'agent/prompt-submit\'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise', - summary: 'Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it.', + 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', - summary: 'A message entered the agent\'s inbox (queued or steering).', + 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', - summary: 'Waterfall: shape the step\'s call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use).', + summary: 'Replace the frozen call configuration.', }, { name: 'agent/session-prefix', mode: 'waterfall', signature: '\'agent/session-prefix\'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise', - summary: 'Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider\'s system slot) on every request this loop instance sends.', + summary: 'Compose the frozen session-stable request prefix once per loop instance.', }, { name: 'agent/session-start', mode: 'emit', signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', - summary: 'The agent\'s session lifecycle began, fired once before its first turn.', + summary: 'The session lifecycle began, once before the first turn.', }, { name: 'agent/status', @@ -306,19 +306,19 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/turn-continuation', mode: 'waterfall', signature: '\'agent/turn-continuation\'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise', - summary: 'Waterfall: override the turn-continuation decision via a typed ContinuationDecision.', + summary: 'Override whether the turn continues.', }, { name: 'agent/turn-stop', mode: 'serial', signature: '\'agent/turn-stop\'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined', - summary: 'Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded.', + summary: 'Monotonic terminal-stop checkpoint after continuation and steering are folded.', }, { name: 'approval/request', mode: 'waterfall', signature: '\'approval/request\'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise', - summary: 'Waterfall asking the composed answerers to decide one approval request.', + summary: 'Ask composed answerers for one decision.', }, { name: 'fs/edit-intent', @@ -348,25 +348,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/created', mode: 'emit', signature: '\'session/created\'(this: Scoped, session: Session): void', - summary: 'A session was created in the store.', + summary: 'Emitted after session publication.', }, { name: 'session/disposed', mode: 'emit', signature: '\'session/disposed\'(this: Scoped, session: Session): void', - summary: 'A previously announced session left the store.', + summary: 'Emitted once when an announced session leaves the store, including publication rollback.', }, { name: 'session/event', mode: 'emit', signature: '\'session/event\'(this: Scoped, session: Session, event: SessionEvent): void', - summary: 'An event was appended to a session log (sync, fire-and-forget).', + summary: 'Post-commit append feed.', }, { name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - summary: 'Awaited durability checkpoint.', + summary: 'Awaited parallel durability checkpoint; dispatch through SessionStore.flush.', }, { name: 'skill/provider-added', @@ -408,13 +408,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - summary: 'Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered.', + summary: 'Expert waterfall over the assembled sections, tools, and variables.', }, { name: 'system-prompt/change', mode: 'emit', signature: '\'system-prompt/change\'(): void', - summary: 'A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only).', + summary: 'Emitted when any prompt provider changes.', }, { name: 'tools/change', @@ -426,25 +426,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/execute', mode: 'waterfall', signature: '\'tools/execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', - summary: 'Around-dispatch waterfall wrapping the registry\'s core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam.', + 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', - summary: 'Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code\'s `PostToolUse`).', + 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', - summary: 'Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code\'s `PreToolUse`).', + summary: 'Allow, deny, or ask before dispatch.', }, { name: 'tools/result', mode: 'emit', signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', - summary: 'Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization.', + summary: 'Observe the frozen, lossless-JSON final outcome.', }, { name: 'workflow/agent-end', diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 33cd0faddd..bb40aaa62f 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -47,4 +47,4 @@ The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loo ## Why a code bundle, not a shared YAML include -A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, means the default leaf for an ACP server has no logger entry to copy wrong — "the ACP app never logs to stdout" stops being a prose warning a leaf must remember and becomes the app package's default shape (a leaf can still add a sibling logger, so the rule stays documented — but it has nothing to get wrong by default). Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order. +A YAML include can deduplicate config but cannot own a bin or enforce front-door composition. App packages make stdout-safe ACP wiring the default instead of a leaf comment. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without depending on load order. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6832800d2e..6315aebd7d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle. +Concrete `ReactLoopAgent` implementation and loop driver. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -8,18 +8,14 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md). -The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. - -IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. - -- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create(options)` creates on the supplied session id and returns an owned [`AgentHandle`](../agent/README.md). +- `ctx.agents.resume(options)` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues the stored history, and returns the same handle shape. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -40,7 +36,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Configured agents start automatically. `cwd` applies only to fresh sessions; `resumeSessionId` retains persisted metadata. They use the deployment persona. Programmatic setup can shadow it per agent. This plugin supplies the per-agent `model` and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Exported concrete class @@ -50,53 +46,9 @@ Agents listed in config are auto-created at startup. `cwd` applies only to fresh ### Loop lifecycle (`loop.ts`) -The internal loop driver runs one agent for its whole lifetime: +The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. -``` -create agent → emit agent/session-start(source) ⟵ once, before turn 1 -forever: - wait for queued messages (idle) - TURN (error-contained): - 'turn/start' - each queued: waterfall agent/prompt-submit → allow (→ session('user/message'), - inject additionalContext) | block (→ session('prompt/blocked'), drop) - if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn - STEP loop: - drain steering - assembly = await systemPrompt.assemble(assembleContextFor(agent)) - ⟵ renderPrompt(assembly) IS the full prompt - prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen - session prefix; on the header, never history - await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step; - pressure gates see the prefix the request carries - boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame, - session('step/start') strictly before step/start - config = waterfall agent/request ⟵ frozen seed; return a replacement to switch - session('request/header'[-delta]) ⟵ the header event this request owes the log - stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk') - message = waterfall agent/step-result - session('assistant/message') - each tool-call: session('tool/call') - → tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification] - → session('tool/result') - append buffered post-execute additionalContext as session('context/message')(s) - drain steering → session('steering/message') - cont = waterfall agent/turn-continuation → ContinuationDecision - ({action:'continue', reason?} records reason as next-step steering) - pending steering can override an ordinary stop - terminal = serial agent/turn-stop → ContinuationStop | undefined - (after ordinary decision/reason/steering folding) - if terminal stop, or ordinary action==stop with no pending steering: break - session('turn/end') - await session/flush - terminal turn: discard steering added before/during close and flush; keep ordinary queued sends - ordinary turn: re-enqueue leftover steering as queued - idle unless more queued -``` - -Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop. - -Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.) +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. ### What is NOT here diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ff66f942be..521ccfedea 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -48,10 +48,7 @@ export interface PreparedReactLoopAgent { } /** - * Construct one concrete agent together with unforgeable, instance-bound - * lifecycle controls. The package surface deliberately exposes neither source - * subpaths nor this helper: setup code may identify the concrete class, but it - * cannot publish or start the factory's unpublished instance. + * Construct an unpublished concrete agent with instance-bound lifecycle controls. * @param ctx - the agent-loop service context used for driving and events. * @param id - the concrete agent identity. * @param options - loop options for the agent. @@ -131,16 +128,7 @@ export class ReactLoopAgent implements Agent { * leave it set to wrongly drop a later prompt. */ private cancelRequested = false - /** - * The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`), - * read by the driver loop's marker branches so a turn dropped in a - * marker-only window (pre-step / continuation, where no `AbortController` - * carries the reason) ends with the SAME `{kind:'aborted', reason}` the - * mid-step abort path produces from `abort.signal.reason`. Without this the - * caller's `cancel(reason)` would be silently replaced by the literal - * 'cancelled' whenever the cancel landed outside a running step — making the - * logged reason race-dependent and the public `reason?` param half-effective. - */ + /** Pending cancellation reason, preserved even outside an active step signal. */ private cancelReason = 'cancelled' private disposed: Promise private resolveDisposed!: () => void @@ -179,11 +167,7 @@ export class ReactLoopAgent implements Agent { private setStatus(status: AgentStatus): void { if (this._status === status || this._status === 'disposed') return this._status = status - // Release quiescence waiters on a transition OUT of running BEFORE emitting - // (the disposer handles the disposed transition separately). Settling first - // means a throwing `agent/status` subscriber cannot starve a `whenIdle()` - // waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must - // not hang on one bad listener). + // Settle first so a throwing status listener cannot starve quiescence waiters. if (status !== 'running') this.settleIdleWaiters() agentEvents(this.loopCtx, this).emit('agent/status', status) } @@ -269,18 +253,7 @@ export class ReactLoopAgent implements Agent { // Decide the durability checkpoint from the log: an accepted one-shot // turn must be flushed even when its message append was the failing step. const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Checkpoint the one-shot turn for durability, exactly as the loop does at - // every turn/end. The loop is NOT running (we are idle), so nothing else - // will flush this turn. Fire-and-forget with error containment: inject() - // is synchronous, and a persistence backend failing must not throw into - // the caller (e.g. a tool-bash task-done callback). Disposal still drains - // independently, so a slow flush is safe. The task is tracked until it - // settles: driver disposal awaits every pending idle-injection checkpoint - // before unregistering the agent or detaching the session. A flush failure - // is reported via agent/error (step 0 — the idle-injection convention, - // there is no real step) AND the logger, mirroring the loop's post-turn/end - // flush path so plugins monitoring agent/error see idle-injection - // persistence failures too. A throwing agent/error listener is contained. + // Track the asynchronous checkpoint so disposal drains it; contain errors. if (turnRecorded) { // Through the store's flush (the carrier owner), never a raw parallel. const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { @@ -290,10 +263,7 @@ export class ReactLoopAgent implements Agent { agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err) }) this.pendingIdleFlushes.add(flush) - // Attach the same retirement callback to both settlement arms so even a - // logger failure in the catch above cannot become an unhandled rejection. - // Teardown uses allSettled for the same reason: a reporting failure must - // not strand ownership. + // Retire on either settlement path. const retire = (): void => { this.pendingIdleFlushes.delete(flush) } void flush.then(retire, retire) } @@ -301,15 +271,7 @@ export class ReactLoopAgent implements Agent { } cancel(reason?: string): void { - // Arm-gate: only mark a cancellation when there is actually work to cancel — - // a running turn, an in-flight step, or queued/steering work. An idle cancel - // with nothing pending is a true no-op; arming the marker then would wrongly - // drop the NEXT legitimate prompt (the marker is consumed only at the loop's - // turn-decision points, which an idle parked loop does not reach until woken - // by a real send()). Note the gate canNOT be `status === 'running'` alone: - // the pre-step window (a send() queued but the loop not yet flipped to - // running) has status `idle` with `hasQueued` true, and the marker exists - // precisely to cover it. + // Arm only for current work; an idle marker would cancel the next prompt. if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) { this.cancelRequested = true // Capture the resolved reason for the marker-only windows (pre-step / @@ -328,30 +290,11 @@ export class ReactLoopAgent implements Agent { this.currentAbort?.abort(reason ?? 'cancelled') } - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`. If it is already disposed, awaits {@link done} (the loop-exit - * promise) — `agent/status('disposed')` fires in the disposer BEFORE the - * driver loop has unwound, so it is NOT itself a quiescence signal. If it is - * idle AND has no queued work, resolves immediately. Otherwise queues an - * internal waiter (see {@link idleWaiters}) released on the next - * running→idle/disposed transition, resolving on `idle` directly (the turn - * fully ended) or chaining {@link done} on `disposed` (wait for the loop to - * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner - * quiescence-observation hook, distinct from teardown (a lifecycle owner stops - * and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits - * both {@link done} and outstanding idle-injection flushes, not through this). - */ + /** Resolve at idle, or after driver exit when disposed. */ whenIdle(): Promise { if (this._status === 'disposed') return this.done if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() - // Register an internal waiter (resolved by settleIdleWaiters on the next - // running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener: - // a concurrent fiber disposal runs this agent's listener disposers, which - // could remove a `ctx.on` waiter before the `disposed` transition fires and - // hang the promise. On disposal the disposer settles the waiter AND we chain - // `done` here for true loop-exit quiescence (status flips to disposed before - // the loop unwinds); a plain idle transition resolves directly. + // Agent-owned waiters survive concurrent fiber disposal. return new Promise((resolve) => { this.idleWaiters.push(() => { resolve(this._status === 'disposed' ? this.done : undefined) @@ -387,12 +330,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, - // Settle whenIdle() waiters WITHOUT a status transition — the pre-step - // cancel-skip path drops the about-to-run turn and re-parks without ever - // flipping running→idle, so a waiter registered in the pre-step window - // (status idle, hasQueued was true) would otherwise hang. This emits no - // agent/status, so an ACP agent/status listener never sees a spurious idle - // that would resolve a freshly-queued prompt as cancelled. + // Pre-step cancellation re-parks without a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) } @@ -432,11 +370,7 @@ export class ReactLoopAgent implements Agent { // cleanup. The normal loop contains turn failures itself; allSettled is the // final lifecycle backstop for anything outside those boundaries. await Promise.allSettled([this.done]) - // No new inject() can start after the synchronous disposed transition. - // Loop because settled tasks retire themselves in promise reactions that - // may run beside this continuation; either the set is empty or this waits - // the exact remaining quiescence boundary. allSettled keeps a failure in - // error reporting from skipping registry/session/scope disposers. + // Repeat because settled flushes retire in adjacent promise reactions. while (this.pendingIdleFlushes.size > 0) { await Promise.allSettled([...this.pendingIdleFlushes]) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 4ad4779176..53f07ba426 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -73,14 +73,7 @@ function signalAbortError(id: AgentId, signal: AbortSignal): Error { return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } -/** - * One create/resume transaction from caller ownership through unpublished - * setup, rollback-covered publication, and final quiescent teardown. - * - * The class deliberately owns the state machine in one place. Registries only - * arbitrate identity at their final `enter()` calls; before that point every - * resource is private to this transaction. - */ +/** Caller-owned create/resume transaction through publication and teardown. */ class AgentCreationTransaction { private active = true private failure: Error | undefined diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 7a7ca2e28c..be7db9852b 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -1,11 +1,4 @@ -/** - * The agent loop driver: one `runLoop()` invocation drives one agent for its - * whole lifetime. Error-contained at the turn level — a throwing plugin ends - * the turn, never kills the loop. See the JSDoc on `runLoop()` for the full - * lifecycle pseudo-code. - * - * @module dsh-agent-loop/loop - */ +/** Agent loop driver with turn-level error containment. @module dsh-agent-loop/loop */ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' @@ -25,33 +18,12 @@ 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 an arbitrary thrown value into a coded Error. A real Error passes - * through (its `code`, if any, is preserved by {@link errorData}); a non-Error - * throw is wrapped in a {@link HarnessError} with code `UNKNOWN` and the - * original value chained as `cause`, so a bad throw still carries a routable - * code instead of degrading to a bare message. - */ +/** Normalize thrown values while preserving an existing error code. */ function toError(error: unknown): CodedError { return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error }) } -/** - * Map a model-call {@link FinishReason} to the step error it should raise, or - * `undefined` when the step completed normally. - * - * Adapters report provider/transport failures one of two sanctioned ways (see - * the StreamChunk contract in dsh-llm): throw from `stream()` (handled by the - * caller's try/catch), OR end the stream with a finish-error/aborted chunk - * (the only option for adapters that can't throw mid-stream, e.g. - * library-backed ones). This translates the latter into a thrown step error - * so the turn ends error/aborted (the failure recorded on `turn/end.reason`), - * never as a normal `completed` assistant message. - * - * `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so - * the switch handles the known terminal-failure kinds and treats every other - * kind — `stop`, `tool-calls`, `max-tokens`, future additions — as success. - */ +/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */ function finishError(finish: FinishReason): CodedError | undefined { switch (finish.kind) { case 'error': { @@ -78,19 +50,7 @@ function errorData(err: CodedError): { message: string; code?: string } { return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} } } -/** - * The turn-end contribution of a step's *successful* finish, or `undefined` - * when the step finished ordinarily (a plain `completed`). - * - * {@link finishError} has already converted `error`/`aborted` finishes into - * thrown step errors, so the finishes that reach here are `stop`, - * `tool-calls`, `max-tokens`, or a future merge-extensible kind. Only - * `max-tokens` carries forward as a distinct {@link TurnEndReason}: a step that - * hit the output-token ceiling ended the turn cut-short rather than by the - * model's choice. `stop`/`tool-calls`/unknown kinds contribute nothing beyond - * the default `completed`. {@link runTurn} applies this with the rule "any - * `max-tokens` step in the turn makes the turn end `max-tokens`". - */ +/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { switch (finish.kind) { case 'max-tokens': @@ -103,11 +63,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined { } } -/** - * Ambient handles the loop driver receives from the agent. Decouples the - * pure function `runLoop` from the mutable ReactLoopAgent fields, making the - * loop testable without a real agent. - */ +/** Mutable agent controls supplied to the loop driver. */ export interface LoopHandle { /** Native-private agent inbox handed to the driver only at internal startup. */ readonly inbox: Inbox @@ -116,122 +72,37 @@ export interface LoopHandle { /** Resolves when the agent is disposed — unblocks the idle wait. */ disposed: Promise isDisposed(): boolean - /** - * Whether a `cancel()` is pending for the current turn. The driver checks this - * at every decision point where a turn could start or continue (right after - * the idle wait, after the `running` flip, before each step, and at the - * continuation gate) and drops the about-to-run / continuing turn. Reset once - * per loop iteration via {@link clearCancel} after the turn returns, so the - * marker governs exactly one cancellation and never leaks to a later prompt. - */ + /** Whether cancellation is pending for the current loop iteration. */ isCancelled(): boolean - /** - * The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read - * by the marker branches (pre-step / continuation) so a turn dropped where no - * `AbortController` carries the reason still records the caller's - * `cancel(reason)` value — matching the mid-step abort path. Only meaningful - * when {@link isCancelled} is true. - */ + /** Resolved pending-cancellation reason; meaningful only while {@link isCancelled} is true. */ cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** - * Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the - * pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the - * idle wait, so no `running→idle` transition fires to settle a `whenIdle()` - * waiter that was registered in the pre-step window — this settles it directly - * (it emits no `agent/status`, so an ACP `agent/status` listener never sees a - * spurious idle that would resolve a freshly-queued prompt as cancelled). - */ + /** Settle idle waiters when a cancelled turn is skipped without a status transition. */ settleIdle(): void } /** - * The agent loop. One invocation drives one agent for its whole lifetime: - * - * ``` - * create agent → emit agent/session-start(source) ⟵ once, before turn 1 - * forever: - * wait for queued messages (idle) - * TURN (error-contained — a throwing plugin ends the turn, never the loop): - * 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror) - * allow → session('user/message'…) (+ inject additionalContext) | block → drop - * every prompt blocked → 'turn/end'(rejected), 0 steps - * STEP loop: - * drain steering → session('steering/message') ⟵ catches late steering - * assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble - * (scope-filtered; scoped sections/tools join); renderPrompt - * (persona section + {{variables}}) IS the full prompt - * prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen - * session prefix; logged on the header, never - * session history (scope-filtered, fused dispatch) - * await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step; - * pressure gates see the prefix the request carries - * boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the - * session('step/start') same sync frame, strictly before step/start - * config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches - * session('request/header'|'request/header-delta') ⟵ the header event this request owes the - * log (initial/resume anchor, delta, fallback) - * req = freeze({header..., messages: prefix+boundary, sessionId, signal}) - * stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req) - * session('assistant/chunk') - * msg = waterfall agent/step-result ⟵ BEFORE the log append, so the - * session('assistant/message' {content, usage?}) session records what actually ran - * each tool-call in msg (sequential, abort-checked): - * session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask) - * → dispatch → tools/post-execute - * session('tool/result') - * append buffered post-execute additionalContext → session('context/message')(s) - * drain steering → session('steering/message') - * session('step/end') ⟵ durable step boundary (no agent/* mirror) - * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default - * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is - * recorded as next-step steering - * if action==stop && steering arrived (step/end/continuation listeners): continue anyway - * terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary - * continuation and steering folding - * if terminal: discard pending steering and break - * if action==stop: break - * session('turn/end') ⟵ durable turn boundary (no agent/* mirror) - * await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier) - * re-enqueue leftover steering as queued ⟵ steering is never stranded - * idle (emit agent/status) unless more queued - * ``` + * 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). * @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads. */ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise { - // Per-instance transmission bookkeeping: whether THIS loop instance has - // anchored the log's header fold yet (its first request logs a - // 'initial'/'resume' request/header snapshot). Everything else the request - // needs is read from the session log itself — the loop holds no - // conversation state (the reconstructability RFC). + // Per-instance prefix and request-header state; conversation history remains in the session log. const transmission = createTransmissionLog() const { session } = agent - // The fused agent-subject dispatcher: every agent/* dispatch below carries - // the agent's scope (an `agent.ctx` listener hears only this agent) with - // the subject injected — one spelling, checked by the dev invariants. + // Fused subject and scope carrier for every agent event below. const events = agentEvents(ctx, agent) while (!handle.isDisposed()) { await handle.inbox.waitForQueued(handle.disposed) if (handle.isDisposed()) break - // Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the - // idle wait but before we flip to `running`. The cancelled queued/steering - // work is already cleared by `cancel()`. Clear the marker, then: - // - if NOTHING new is queued, drop the about-to-run turn and re-park, - // settling any `whenIdle()` waiter DIRECTLY (no running→idle transition - // fires here to settle it) and WITHOUT emitting `agent/status` (an ACP - // listener must not see a spurious idle that resolves a freshly-queued - // prompt as cancelled); - // - if a NEW prompt was queued AFTER the cancel (a send() that raced in - // before the loop resumed), the marker was for the cancelled work only — - // fall through and run the new prompt's turn. Do NOT settle waiters here: - // a whenIdle() waiter must wait for that new turn's running→idle, not - // resolve before it runs (the quiescence contract). + // Cancellation between wake and `running` skips only the cancelled work; + // a replacement prompt still runs and owns the eventual idle transition. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -242,18 +113,8 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH handle.setStatus('running') - // Pre-step cancel (window 2): `setStatus('running')` emits `agent/status` - // SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the - // check above and `runTurn`. Mirror window 1: clear the marker, then - // - if NOTHING new is queued, drop the about-to-run turn and transition - // back to `idle` (`running` was already emitted, so a real idle - // transition balances the status AND settles `whenIdle()` waiters); - // - if a NEW prompt was queued AFTER the cancel (a `running` listener that - // cancels then sends), the marker was for the cancelled work only — fall - // through and run the new prompt's turn (status is already `running`), so - // a `whenIdle()` waiter resolves on THAT turn's running→idle, not before - // it runs. Settling here would resolve quiescence while the replacement - // is still queued and unrun (the same early-resolve race window 1 fixes). + // A synchronous `running` listener can cancel before `runTurn`; balance the + // status only when no replacement prompt was queued by that listener. if (handle.isCancelled()) { handle.clearCancel() if (!handle.inbox.hasQueued) { @@ -262,24 +123,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } } - // Re-derive the turn number from the log each iteration (do NOT keep a local - // counter): an idle `agent.inject()` can append its own one-shot turn while - // the loop waits above, so the next real turn must continue from whatever - // turn number is actually last in the log — a stale counter would collide. + // Idle injection can add a turn, so derive the next number from the log. const turn = lastTurnNumber(session) + 1 let terminalStopped = false try { terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission) } catch (error: unknown) { - // Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard - // before turn/start) — no turn/start was appended, so no turn is open and - // none is owed. A session `error` here would land outside any turn (after - // the previous turn/end), where the persistence backend drops it as a - // crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the - // driver survives and moves on. - // Acceptance and internal dispatch validation can reject before - // turn/start commits. Report that supported pre-turn failure without - // inventing a turn/end for a turn that never opened. + // Pre-turn failure has no durable boundary to close; report it without appending outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`) try { @@ -287,21 +137,10 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH } catch { /* contained: a throwing agent/error listener must not kill the driver */ } } - // Reset the cancel marker UNCONDITIONALLY here, after the turn returns and - // before the next iteration's idle wait. NOT gated on the idle transition - // below: a `send()` that lands during the cancelled turn's flush window makes - // `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset - // would never fire and the stale marker would wrongly drop that next prompt's - // turn. Resetting per iteration scopes the marker to exactly the turn that was - // cancelled. + // Reset per iteration, including when a prompt arrives during the flush window. handle.clearCancel() - // Steering that arrived too late to join an ordinary turn (turn-end - // listeners, flush) becomes queued input so it is never stranded. A - // terminal-stop owner is the deliberate exception: discard the steering - // again after the close + flush window so terminal policy cannot be undone - // after its in-turn drain. Ordinary queued sends live in a separate FIFO and - // remain untouched. + // Late steering becomes queued input unless terminal policy stopped the turn. for (const message of handle.inbox.drainSteering()) { if (!terminalStopped) handle.inbox.enqueue(message) } @@ -315,10 +154,7 @@ async function runTurn( ): Promise { const { session } = agent - // --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end — - // turn/start has not been appended — so it propagates to runLoop's backstop - // untouched. The queued messages are drained here but appended AFTER - // turn/start (below), so every event in the log lives inside a turn. + // Drain before opening the turn, but append only after `turn/start`. const queued = handle.inbox.drainQueued() const first = queued[0] /* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */ @@ -331,28 +167,17 @@ async function runTurn( let errorReported = false let terminalStopped = false - // Close the open step exactly once (idempotent via stepOpen). Post-commit - // session/event observers are contained by Session; a pre-commit validator - // failure still escapes so the outer recovery path may retry the boundary or - // fail loudly without pretending an uncommitted step/end exists. + // Close the committed step once; pre-commit validation failure still escapes. const closeStep = (): void => { if (!stepOpen) return session.append('step/end', { turn, step }) stepOpen = false } - // Record a step/turn failure exactly once: set the error reason (carrying the - // failing `step` — the durable failure lives entirely on turn/end.reason, there - // is no separate session error event) and emit agent/error (contained — trap: a - // throwing agent/error listener must not re-escape and strand the turn). - // Disposal and abort set `reason` directly without calling this (they are not - // failures). + // Record the durable turn failure once and contain the live error notification. const failTurn = (err: CodedError): void => { if (errorReported) return errorReported = true - // The turn is still open here. Post-commit observers cannot escape append, - // and a pre-commit turn/end veto leaves no closing boundary to overwrite. - // Set the reason that the next successful closeTurn will append. reason = { kind: 'error', step, ...errorData(err) } try { events.emit('agent/error', turn, step, err) @@ -362,9 +187,7 @@ async function runTurn( } } - // Close the turn. Post-commit observer failures are contained by Session; - // pre-commit validation failures escape to recovery instead of being mistaken - // for a committed boundary. Turn boundaries are durable session events only. + // Pre-commit validation failure escapes rather than masquerading as a committed boundary. const closeTurn = (): void => { session.append('turn/end', { turn, reason }) } @@ -414,11 +237,7 @@ async function runTurn( } while (true) { - // A fully-blocked batch (every prompt vetoed by prompt-submit) opens a - // zero-step turn that ends `rejected`: break BEFORE the first step so the - // boundary stays balanced (turn/start → turn/end) and the block is a - // durable in-turn fact. `anyAllowed` never changes inside the loop, so this - // only ever fires on the first iteration. + // A fully blocked batch closes its zero-step turn as rejected. if (!anyAllowed) { reason = { kind: 'rejected', reason: lastBlockReason } break @@ -437,48 +256,18 @@ async function runTurn( const abort = new AbortController() handle.setAbort(abort) - // Assemble the system prompt for this step. Done HERE (before step/start) - // because the pre-step seam needs it: compaction measures token pressure - // against the system prompt (it counts toward the budget). runStep reuses - // this same assembly for the request, so the prompt is assembled once per - // step. renderPrompt IS the full prompt — the persona is the order-0 - // section (owned by dsh-system-prompt) and `{{variable}}` - // interpolation happens in the render, so there is no separate join. + // Assemble once before pre-step so pressure checks and the request share the same prompt. const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent)) const fullSystemPrompt = renderPrompt(assembly) - // Interruption landing after assembly: dispose() or cancel() in a - // turn-start listener (or a listener whose promise resolved before the - // await above) arms either handle.isDisposed() or handle.isCancelled(). - // The Abort was created first, so any concurrent abort also lands on it. - // Drop the about-to-start step WITHOUT running the seam — no step is open - // yet, so end the turn accordingly (disposed wins for an unambiguous - // reason). + // Cancellation or disposal during assembly ends the turn before any step opens. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } break } - // Compose the session prefix ONCE per loop instance, lazily before the - // instance's first pre-step: request-only messages placed in front of - // the ENTIRE derived history on every request this instance sends. It - // MUST precede the pre-step seam so compaction gates on THIS instance's - // prefix — reading a previous instance's logged prefix would let a - // resumed/forked instance whose contributor grew skip compaction and - // ship an over-window first request. The result is deep-cloned - // (decoupled from listener-held references), deep-frozen, and cached on - // the transmission bookkeeping, so reuse is structural — the prefix - // cannot change mid-session and the provider prefix cache holds by - // construction (resume = a new instance = a recompose, anchored by its - // 'resume' snapshot). The prefix is not session history — the header - // event in runStep is its only durable record - // (EpochHeader.messagePrefix). The frozen empty seed serves both the - // listener chain and the no-listener fallback: a contribution is a - // RETURNED extension of `await next()`, never an in-place push. This - // runs OUTSIDE the step, before the boundary snapshot: a composing - // listener's session append lands before the boundary and joins the - // CURRENT request. + // Compose, detach, and freeze the per-instance prefix before pressure checks. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -486,16 +275,7 @@ async function runTurn( () => Promise.resolve(emptyPrefix), ) - // Interruption landing during prefix composition: mirror the assembly - // window above — drop the about-to-start step without running the - // seam, and DISCARD the composition instead of caching it. An - // abort-aware listener may have returned a degraded fallback under - // the firing signal; committing it would ship a prefix no request - // ever used (and no header ever logged) on this instance's next real - // request. The next turn recomposes under a live signal — the cache - // only ever holds a fully composed prefix. The cache-hit path needs - // no such check: nothing awaits between the assembly check above and - // the pre-step seam. + // Never cache an interrupted composition; the next turn recomposes it. if (handle.isCancelled() || handle.isDisposed()) { handle.setAbort(undefined) reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() } @@ -504,19 +284,7 @@ async function runTurn( transmission.sessionPrefix = deepFreeze(structuredClone(composed)) } - // Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the - // step: after `turn/start` (and the prior step's close) but before - // `step/start`, so a compaction's log-only `compact/*` records and its - // replacement node land cleanly outside any step (honest structure that - // crash-safety relies on — a dangling `compact/start` sits before the - // synthetic `turn/end` repair appends). Serial (awaited, in order, no - // veto): each listener completes its surface mutation before the next, so - // concurrent listeners cannot interleave their `session.append`s. A - // throwing listener escapes to the outer catch, which closes the (not-yet- - // open) step as a no-op and ends the turn via failTurn — a broken - // pre-step plugin ends the turn, not the loop. The composed session - // prefix rides along so token-pressure listeners count everything the - // request will actually carry. + // 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) // Interruption landing during the pre-step seam: do not open an empty step. @@ -526,16 +294,7 @@ async function runTurn( break } - // The reconstruction boundary (the reconstructability RFC): the request's - // messages are snapshotted HERE, in the same synchronous frame as the - // step/start append directly below — so the snapshot is exactly the - // derivation over the log prefix strictly before step/start's seq. - // Anything appended later by the request-window inject seam or a - // concurrent task lands after the boundary and joins the NEXT request. - // session/event itself is observe-only: append reentrancy is rejected - // until the current callback list drains. An external reconstructor - // recovers these exact messages by folding the surface over - // events[0..stepStartSeq). + // Snapshot the exact log prefix before step/start: the reconstruction boundary. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) @@ -582,13 +341,7 @@ async function runTurn( break } - // The successful step's finish reason carries forward: a `max-tokens` - // step makes the whole turn end `max-tokens` (the ACP RFC's rule "any - // max-tokens step surfaces as max-tokens"). `stepFinishReason` returns - // `max-tokens` or `undefined`, so a later ordinary step never resets a - // max-tokens turn back to completed, and a never-truncated turn keeps the - // default `completed`. The disposal/abort/error branches above and the - // continuation-window disposal check below override this — they win. + // Preserve max-token completion unless a later disposal, abort, or error wins. const stepReason = stepFinishReason(stepOutcome.finish) if (stepReason) reason = stepReason @@ -610,24 +363,16 @@ async function runTurn( break } - // A forced `continue` may carry model-facing context: record it as - // next-STEP steering (the steering channel), so the continued turn's next - // iteration drains it before its request — the typed twin of the /goal - // step/end-steer pattern. + // A continuation reason becomes next-step steering. if (decision.action === 'continue' && decision.reason) { handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source }) } let shouldContinue = decision.action === 'continue' - // Steering from step/end session-event or continuation listeners (the - // /goal pattern) demands the model see it — it overrides a stop decision; - // the next iteration's drain records it. + // Pending steering overrides an ordinary stop. if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true - // Terminal policy runs only AFTER the extensible continuation waterfall, - // its optional reason, and late steering have all been folded. Unlike the - // waterfall, this serial seam is monotonic: the first stop bail wins, and - // no later listener or steering override can resurrect the turn. + // Terminal policy is monotonic and runs after ordinary continuation folding. let terminalStop = false try { const stop = await events.serial('agent/turn-stop', turn) @@ -640,19 +385,12 @@ async function runTurn( } if (terminalStop) { terminalStopped = true - // A continuation reason or listener may have queued steering before the - // terminal checkpoint. Discard only steering (never ordinary queued - // prompts) so it cannot become a next step or be re-enqueued as a fresh - // turn by runLoop's late-steering fallback. + // Terminal stop discards steering but preserves ordinary queued prompts. handle.inbox.drainSteering() shouldContinue = false } - // A cancel that landed during the continuation window — after the step's - // AbortController was cleared (setAbort(undefined)) but before the next - // step starts — has no controller to observe it, so the turn-scoped marker - // ends the turn here. cancel() also cleared the steering FIFO, so the - // override above did not re-arm continuation. + // The marker catches cancellation after the step controller was cleared. if (handle.isCancelled()) { reason = { kind: 'aborted', reason: handle.cancelReason() } break @@ -668,19 +406,11 @@ async function runTurn( // Normal / inline-error loop exit: close the turn. closeTurn() } catch (error: unknown) { - // Decide whether this turn opened from the LOG, not a speculative flag. A - // pre-commit validator or acceptance failure leaves no turn/start and owes - // no turn/end, so it propagates to runLoop's backstop. Once turn/start is - // present, this path balances any committed step and records the failure. + // Close only a turn whose start committed to the log. const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) if (!turnStartLogged) throw error closeStep() - // Choose the close reason. Disposal wins only if no error was already - // reported: a turn disposed mid-step sets reason=disposed in the step-error - // branch (without reporting an error), so preserve disposed rather than - // overwrite it. Otherwise a mid-step throw on a live agent is a real - // failure → failTurn. (errorReported is mutated only inside the failTurn - // closure, which the analyzer can't follow, hence the inline lint-disable.) + // Preserve an established disposal reason; otherwise report the failure. if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition reason = { kind: 'disposed' } } else { @@ -689,19 +419,11 @@ async function runTurn( closeTurn() } - // Durability checkpoint: persistence plugins drain write-behind buffers. - // A failing persistence plugin is reported but doesn't kill the agent. - // Through the store's flush (the carrier owner), never a raw parallel. + // Flush through the store-owned durability checkpoint without killing the driver on failure. try { await ctx.sessions.flush(session) } catch (error: unknown) { - // The turn is already closed (turn/end appended above) and flush must run - // AFTER turn/end to be a checkpoint — so there is no in-turn position left - // for a session `error` event. Appending one here would land it after the - // last turn/end, where the persistence backend treats it as a crash tail - // and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report - // the failure via agent/error + the logger only; persistence keeps the - // buffered events for the next flush/dispose, so nothing is lost. + // The turn is closed, so report the failed flush live rather than append outside a turn. const err = toError(error) ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`) try { @@ -743,40 +465,23 @@ async function runStep( ): Promise<{ hadToolCalls: boolean; finish: FinishReason }> { const { session, options } = agent - // Seed the call config: the first request of THIS loop instance seeds from - // current AgentOptions — explicit options always win over the logged - // baseline, which is what keeps fork model-overrides and resume-time - // reconfiguration correct. Later steps seed from the log's folded header, - // which by then is exactly what this instance last logged. - // One deep-cloned, frozen seed serves BOTH the listener chain and the - // no-listener fallback: structuredClone decouples it from the session's - // cached header fold (a raw reference would let a delegating listener - // mutate the fold in place and silently skip the delta log), and the freeze - // makes in-place shaping unrepresentable — a switch is a RETURNED - // replacement, which the header event below records. + // Seed the first request from agent options and later requests from the logged header; + // detach and freeze so listeners must return an attributable replacement. const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log ? session.requestHeader()!.config : { model: options.model ?? '' })) - // Shape the call config: listeners return a replacement to switch model or - // sampling (the seed is frozen — content shaping is not expressible here; - // model-visible content flows through the log channels). The header event - // below records whatever the request ACTUALLY uses, so a listener's switch - // is a logged, reconstructable fact, never silent drift. + // Listener replacements are recorded in the request header before dispatch. const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig)) if (!config.model) { throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`) } - // The session prefix was composed (once per instance) before this step's - // pre-step seam — the caller guarantees it, so the cache is always set here. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call const sessionPrefix = transmission.sessionPrefix! - // The request header (the log's request/header* vocabulary): canonical form, - // recorded before dispatch so the log always explains the request — - // including the session prefix, which no other event carries. + // Record the canonical header, including the otherwise-unlogged prefix, before dispatch. const header = canonicalHeader({ config, ...system ? { system } : {}, @@ -785,11 +490,7 @@ async function runStep( }) recordRequestHeader(session, transmission, header) - // Build and freeze: the request is a pure function of (boundary snapshot, - // logged header) — llm/stream listeners and adapters read it, mutation - // throws. sessionId + frozen is the loop-built marker the dev invariant - // keys on. Message order: header.messagePrefix, then the boundary - // snapshot — the reconstruction equation the invariant recomputes. + // Freeze the logged header plus boundary snapshot; the prefix precedes derived history. const request: GenerateOptions = deepFreeze({ model: header.config.model, messages: [...header.messagePrefix ?? [], ...boundaryMessages], @@ -813,26 +514,16 @@ async function runStep( assembler.push(chunk) } - // Adapters report provider/transport failures one of two sanctioned ways - // (see the StreamChunk contract in dsh-llm): throw from stream() — already - // handled by the caller's try/catch — OR end the stream with a - // finish-error/aborted chunk. finishError() maps the latter to the step - // error to raise (turn ends error/aborted, not a normal completed message). + // Normalize failure finish chunks into the same path as thrown stream errors. const stepError = finishError(assembler.finish) if (stepError) throw stepError if (assembler.finish.kind === 'max-tokens') { let message: Message = withoutToolCalls(assembler.message()) message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))) - // Fire the assistant/message when there is content OR usage: a max-tokens - // step can be cut off with empty content but still carry token accounting, - // and assistant/message is the only host for usage (there is no standalone - // usage event). An empty-content assistant/message is skipped by - // deriveMessages(), so hosting usage on it never injects a spurious assistant - // turn into derived history. + // Preserve usage even when max-token truncation produced no content. if (message.content.length > 0 || assembler.usage) { - // A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is - // never empty here — pass the provenance unconditionally. + // The finish chunk guarantees non-empty provenance here. session.append( 'assistant/message', { turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) }, @@ -842,20 +533,11 @@ async function runStep( return { hadToolCalls: false, finish: assembler.finish } } - // The step-result waterfall runs BEFORE the session append so the log (the - // source of truth for derived history and replay) records the message that - // tool dispatch actually uses. + // Record the post-waterfall message that tool dispatch uses. let message: Message = assembler.message() message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)) - // Same content-or-usage guard as the max-tokens branch: a step that finishes - // with neither assembled content nor usage (e.g. a bare `stop` finish that - // streamed nothing) records no assistant/message — an empty-content message - // exists only to host usage, and deriveMessages() skips it either way, so - // appending one with no usage would be a pure trace-only row. - // - // sourceEventSeqs records the assistant/chunk provenance, but is omitted when - // no chunks streamed (the surface invariant rejects an empty sourceEventSeqs). + // Empty messages exist only to carry usage; omit empty provenance. if (message.content.length > 0 || assembler.usage) { session.append( 'assistant/message', @@ -864,15 +546,9 @@ async function runStep( ) } - // --- Tool execution (sequential; parallel execution is a TODO) --- - // ToolRegistry.execute converts tool failures (including aborts) into - // isError results, so abort is re-checked around every call here. + // Tool execution stays sequential; recheck abort around each normalized result. const toolCalls = message.content.filter(block => block.type === 'tool-call') - // Per-step buffer of `additionalContext` attached by tools/post-execute - // listeners. Appended as context/message(s) only AFTER every tool/result for - // the step, so a multi-call step keeps tool-call/result adjacency - // (interleaving context between a call's result and the next call's would - // break the pairing the next model request relies on). + // Buffer context until all results are appended to preserve call/result adjacency. const pendingContext: HookContext[] = [] for (const call of toolCalls) { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ @@ -884,12 +560,7 @@ async function runStep( } catch { parsedArguments = call.arguments } - // TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite - // `arguments` — tool/call (the audit record) and assistant/message (the - // model-history source) are logged BEFORE execute, and live consumers (ACP, - // tool-bash presentation) read the pre-execution args, so an execution-only - // rewrite would desync the UI from what ran. Designing that consistently is - // its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md). + // TODO(pre-tool-input-rewrite): A rewrite must keep logged history and live presentation aligned. const result = await ctx.tools.execute({ callId: call.id, name: call.name, @@ -899,33 +570,23 @@ async function runStep( }) session.append('tool/result', { turn, step, - // The correlation id MUST be the loop's authoritative call.id (the - // model-transcript id that deriveMessages turns into toolCallId), NOT - // result.callId — a post-execute waterfall listener returning a - // mismatched id would otherwise orphan the call↔result pairing in the - // next model request. A listener-internal id, if ever needed, belongs in - // a separate diagnostic field, never overloaded onto callId. + // Preserve transcript pairing even if a post-execute listener returns another id. callId: call.id, content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. + // Persist tool-owned presentation data for replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] }) - // Buffer (don't append yet) any post-execute additionalContext for this call. if (result.additionalContext) pendingContext.push(result.additionalContext) - // signal CAN flip during the await above (abort() inside a tool); - // the analyzer can't see through the await boundary. + // The signal may flip while the tool is awaited. /* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) /* v8 ignore stop */ } - // Append buffered post-execute context AFTER every tool/result, preserving - // tool-call/result adjacency across the whole batch. inject() appends into the - // open turn (a context/message at its chronological position). + // Append buffered context after the complete result batch. for (const context of pendingContext) { agent.inject(context.content, { source: context.source }) } @@ -948,13 +609,8 @@ export function lastTurnNumber(session: Session): number { } /** - * Whether a turn is currently open in the session log (a `turn/start` with no - * matching later `turn/end`). Decided from the LOG, not agent status: status - * can be `running` while no turn is open (an `agent/status` listener firing - * before `turn/start`, or the post-`turn/end` flush window before status - * returns to idle), so status is not a reliable open-turn signal. Used by - * `inject()` to choose between appending into an open turn vs. wrapping the - * injection in its own one-shot turn (the turn-enclosure RFC). + * Whether the session log has an unmatched `turn/start`. Agent status is not + * sufficient during pre-start and post-end windows. * @param session - the session whose log is inspected. * @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet. */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7f65bbc5f3..73c9b88c43 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -167,10 +167,7 @@ describe('ReactLoopAgent', () => { let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // Non-serializable injected content makes Session.append throw AFTER - // turn/start was recorded. The turn/end must still be appended (finally), - // AND the durability checkpoint must still fire — the balanced turn is in - // memory and a crash before the next turn/dispose would otherwise lose it. + // A post-turn-start append failure still closes and checkpoints the turn. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) }).toThrow(/non-JSON-serializable/) @@ -366,10 +363,7 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { - // Covers the waiter's disposed arm: whenIdle() queues an internal waiter - // while running (not the fast path), then the disposer settles it and chains - // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct - // internal driver disposer keeps the emit synchronous. + // The disposed waiter must chain the driver exit, not resolve eagerly. const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -395,11 +389,7 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { - // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: - // disposing the OWNING fiber runs the agent's listener disposers, which would - // have dropped a ctx.on-based waiter before the 'disposed' transition and - // hung the promise. With internal waiters, the fiber disposer still settles - // it. Regression for the round-3 whenIdle finding. + // Fiber disposal must settle the agent-owned waiter. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: ReactLoopAgent @@ -417,10 +407,7 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => { - // The disposer emits agent/status('disposed') BEFORE the driver loop - // unwinds, so whenIdle() must chain `done` (true quiescence) on the - // disposed path. Dispose a running agent, then assert whenIdle() resolves - // only after `done` — i.e. the loop has actually exited. + // Disposed status precedes driver exit; whenIdle must await both. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: ReactLoopAgent diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5898a82ee2..b2e83920d8 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -168,21 +168,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { }) it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => { - // The /goal pattern steers from a step boundary so the model addresses a - // standing goal before stopping. Step boundaries have no agent/* mirror, so - // the surviving hook point is the durable step/end session event. With a - // no-tools first step the default continuation is stop; the steering queued - // here must force the `!shouldContinue && hasSteering` override so the SAME - // turn runs another step. - // - // The override is what this test guards, so it asserts the same-turn shape — - // NOT merely that the content reaches requests[1]. Without the override the - // turn would stop, and leftover steering is re-enqueued as a next-turn queued - // message, which ALSO lands in requests[1] (just one turn later). So a - // content-only assertion passes with the override disabled and guards - // nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with - // TWO steps and the steering recorded as a `steering/message` BEFORE step 2; - // re-enqueue fallback ⇒ TWO turns. + // Assert the same-turn shape; content alone cannot distinguish re-enqueue. const adapter = new MockAdapter([ textResponse('no tools, would stop'), textResponse('after goal reminder'), @@ -200,12 +186,10 @@ describe('HIGH: steering from late extension points is never stranded', () => { send(agent, 'go') await waitForIdle(ctx, agent) - // Same-turn continuation: the steering forced step 2 within turn 1. const events = [...agent.session.events] expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(events.filter(e => e.type === 'step/start')).toHaveLength(2) - // The steered content is recorded as steering (same turn), BEFORE step 2 — - // not as a fresh turn's user/message. This is the mechanism the override uses. + // Same-turn steering precedes the second step. const steeringIdx = events.findIndex(e => e.type === 'steering/message') const step2Idx = events.map(e => e.type).lastIndexOf('step/start') expect(steeringIdx).toBeGreaterThanOrEqual(0) @@ -582,10 +566,7 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => { describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { - // The second sanctioned adapter error path (besides throwing): an - // adapter that cannot throw mid-stream ends the stream with a - // finish-error chunk (e.g. the pi-ai adapter mapping a provider 401). - // The loop must NOT log a normal assistant/message + completed turn. + // A finish-error chunk must not produce a completed assistant turn. const errorStream: StreamChunk[] = [ { type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } }, ] @@ -652,10 +633,7 @@ describe('step boundary publication order', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) - // Session.append pushes the event BEFORE notifying session/event listeners, - // so a step/start listener always finds the matching event already in the - // log. (Step boundaries have no agent/* mirror — the session log is the live - // feed.) + // Append commits before observers run. const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = [] ctx.on('session/event', (subject, event) => { if (subject !== agent.session || event.type !== 'step/start') return @@ -678,10 +656,7 @@ describe('step boundary publication order', () => { }) describe('turn and step boundary recovery', () => { - // Harness with the invariants plugin loaded as an oracle: it throws on - // append if the log goes unbalanced (turn/end while a step is open, - // turn/start while a turn is open, etc.), so a regression surfaces as an - // InvariantError on the NEXT turn's append rather than a silent imbalance. + // The invariants plugin makes an unbalanced log fail the test. async function balancedHarness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -833,9 +808,7 @@ describe('turn and step boundary recovery', () => { }) it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => { - // First turn: model stream ends with a finish-error → step error path → - // failTurn emits agent/error, whose listener throws. The turn must still - // close balanced. Second turn proves the loop survived. + // Listener failure cannot interrupt error finalization or the next turn. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) @@ -894,9 +867,7 @@ describe('turn and step boundary recovery', () => { }) it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => { - // A pre-step listener requests disposal and then throws before the ordinary - // post-listener disposal check. The outer catch sees disposal already won - // and must preserve reason=disposed rather than rewrite it as a plugin error. + // Disposal remains authoritative when the listener also throws. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent @@ -908,9 +879,6 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/pre-step', () => { if (threw) return threw = true - // Request disposal, then throw in the same synchronous tick: status flips - // to 'disposed' (the disposer aborts the step controller) and the throw - // drives control into the outer catch with isDisposed() already true. void fiber.dispose() throw new Error('boom pre-step during disposal') }) @@ -1001,10 +969,7 @@ describe('turn and step boundary recovery', () => { }) it('a throwing step/end observer cannot interrupt error finalization', async () => { - // A finish-error stream opens a step then fails it, driving finalization - // through closeStep() with the step open. Session contains the observer - // failure after committing step/end, so closeTurn still records the model - // failure and balances the turn. + // Observer failure after step/end commit cannot interrupt turn finalization. const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) @@ -1112,11 +1077,7 @@ describe('tool result call identity', () => { describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => { it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => { - // An empty stream yields zero assistant/chunk events (finish defaults to - // `stop`), so chunkSeqs is empty. A step-result listener injects content, so - // the content-or-usage guard fires and an assistant/message is appended. Its - // sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects - // an empty sourceEventSeqs, and the dev invariants plugin would throw on it. + // Injected result content with no chunks must omit empty sourceEventSeqs. const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) @@ -1143,12 +1104,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream describe('disposal and cancellation during pre-step assembly', () => { it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { - // Block `system-prompt/assemble` on a promise. Start disposal (which - // calls stop() synchronously, setting status=disposed), then release the - // block. The loop must check isDisposed() after assembly and end the turn - // `disposed` — no LLM call. Don't await fiber.dispose() before releasing - // the blocker: the dispose chain awaits agent.done, which hangs until the - // loop unblocks. + // Release assembly only after disposal has marked the agent disposed. const adapter = new MockAdapter(['hang']) let releaseAssemble!: () => void const blocked = new Promise(r => void (releaseAssemble = r)) @@ -1163,7 +1119,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) - // Blocking listener on the parent context (survives fiber disposal). + // Parent-owned listener survives agent-fiber disposal. const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) { await blocked return next() @@ -1259,9 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => { }) it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { - // Block the `agent/pre-step` serial seam on a promise we control, then - // dispose the agent's fiber. When the block releases, the loop must see - // isDisposed() at the post-seam check and end the turn disposed. + // Release pre-step only after disposal has marked the agent disposed. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) @@ -1312,8 +1266,7 @@ describe('disposal and cancellation during pre-step assembly', () => { }) it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => { - // Block `agent/pre-step`, then cancel() the agent. When the block releases, - // the post-seam check catches cancellation and ends the turn aborted. + // Release pre-step after cancellation to exercise the post-seam check. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ee324b0c3..2394c57b34 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -8,28 +8,28 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +`Agent.ctx` owns registrations visible only to that agent. `agentEvents()` couples event subjects to their scope carrier, and `assembleContextFor()` couples the agent and prompt scope. Creation and resume may compose this context through `setup`; the agent remains unpublished and must not be driven until creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) -Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. +The loop plugin registers `AgentFactory`, keeping consumers independent of its concrete package. Each call is traced through the caller's context so the caller owns the resulting transaction and handle. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options)` creates and composes an unpublished session and agent, then atomically enters the registries and starts the loop. A creation-only signal cancels before publication; same-ID contenders arbitrate at entry and losers roll back. +- `ctx.agents.resume(options)` loads a persisted session and follows the same composition and publication boundary. It requires [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). -`AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. +`AgentHandle = { agent, dispose }` is the consumer teardown capability; registry observers receive only the bare agent. Disposal stops and drains the loop and idle-injection flushes before unregistering the agent, detaching its session, and unwinding its scope. Caller and factory unload share that memoized boundary. ### Live events `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -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. +`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. 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). diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index cbb641d271..eb8257f72d 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,16 +1,4 @@ -/** - * Fused scope-carrier dispatch for agent-subject operations, plus the assembly - * context builder. The sanctioned ordinary spelling is - * `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope - * carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as - * the first argument in one move, so a site cannot name a different subject. - * The registry lifecycle pair is the deliberate exception: `enter()` captures - * one stable carrier before commit and `announce()`/detach dispatch through it - * directly, so both lifecycle edges use the same routing identity. The dev - * scoped-dispatch invariant checks both shapes. - * - * @module @deepseek-ai/dsh-agent/dispatch - */ +/** Agent-scoped subject dispatch and prompt assembly context helpers. @module @deepseek-ai/dsh-agent/dispatch */ import type { Context, Events } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -74,9 +62,7 @@ export interface AgentEventDispatch { } /** - * Build the fused dispatcher for `agent`'s events (see the module doc). Cheap - * (one carrier + one small object) — dispatch sites create it per run/turn - * rather than caching it on the agent. + * Build a dispatcher that couples the agent subject to its scope carrier. * @param ctx - the context to dispatch through (any context of the app). * @param agent - the subject agent; also the scope-carrier key. * @returns the fused dispatcher. @@ -121,11 +107,7 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } /** - * The assembly context for one agent's prompt: the typed `agent` DX field and - * the `scope` layer selector, set together (setting `agent` without `scope` - * silently drops the agent's scoped sections/tools from the assembly — the - * dev invariants flag it). THE way the loop (and any custom driver) builds - * its per-step `ctx.systemPrompt.assemble(…)` input. + * Build the prompt assembly context with agent and scope set together. * @param agent - the agent the assembly is for. * @returns the context to pass to `assemble()`. */ diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b77e3e8a60..172a8903f2 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -31,59 +31,23 @@ declare module 'cordis' { } } -/** - * Options for programmatically creating an agent through the registry factory - * ({@link AgentRegistry.create}). The caller supplies the live `sessionId` - * (e.g. an ACP-generated id) and optional session metadata (the validated - * `cwd`, fork lineage); the factory creates the session, the agent, and wires - * them together. - */ +/** Options for creating an agent and its caller-named session. */ export interface CreateAgentOptions { /** The agent's id (the registry handle). */ readonly agentId: AgentId /** The live session's id (NOT derived from agentId). */ readonly sessionId: SessionId - /** - * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, and the `seedLength` seed boundary. Mirrors the - * `cwd`/`parentSession`/`seedLength` fields of - * {@link CreateSessionOptions.meta} in dsh-session (the internal-only - * `createdAt`, used when reconstructing a persisted session, is deliberately - * excluded — a factory caller never sets it). This is durable session data, - * so the session boundary validates and snapshots it before asynchronous - * setup begins. - */ + /** Durable session metadata, validated and detached before setup. */ readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number } - /** - * Seed events to reconstruct the child session's log from (the fork lineage - * primitive). When present, the factory creates the session with this event - * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the - * in-process FORK subagent backend to seed a child with a balanced - * completed-turn prefix of the parent's log. The prefix MUST be contiguous - * from seq 0, carry only lossless-JSON data, and be balanced (no open - * turn/step, no dangling tool-call), or the session constructor (and the - * dev-mode invariants replay) reject it. The factory passes the raw seed to - * the session's durable validator/snapshot boundary. Absent for a fresh - * (spawn) child. - */ + /** Balanced contiguous event prefix for a forked session. */ readonly seed?: readonly SessionEvent[] /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */ readonly signal?: AbortSignal /** - * Creation-time composition of the agent's scoped world. The factory awaits - * setup after minting `agentCtx` but BEFORE inserting or announcing either - * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. - * - * **Setup composes, it never drives**: the callback is trusted same-process - * code and receives the full scoped context, so this is a contract rather - * than a runtime restriction. Drive the agent only after creation resolves. + * Compose the unpublished scoped context before lifecycle announcements. + * Failure rolls back without publishing either id; setup must not drive the agent. */ readonly setup?: (agentCtx: Context) => Promise | void } @@ -101,35 +65,14 @@ export interface ResumeAgentOptions { readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ readonly signal?: AbortSignal - /** - * Resume-time composition of the agent's fresh scoped world. Persistence is - * loaded first; the factory then mints `agentCtx` and awaits setup while the - * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. - */ + /** Compose the unpublished scoped context after persistence load. */ readonly setup?: (agentCtx: Context) => Promise | void } /** - * An owned agent plus its disposer, returned by {@link AgentRegistry.create} / - * {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers, - * only the holder can tear this agent down. The registered factory provider is - * also a structural owner because the scoped agent depends on that provider's - * service surface; provider unload stops and drains every live handle it made. - * `dispose()` stops the loop, awaits its exit and every outstanding - * idle-injection flush (quiescence — NOT just the `disposed` - * status flip), unregisters the agent, removes its session from the store, and - * finally unwinds its scoped world. This order captures every agent-started - * `session/flush` before the session is detached and keeps scoped listeners - * alive through those checkpoints. - * - * `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is - * exposed only to the consumer owner that created it; the structural provider - * reaches the same teardown internally. Config-created agents (the loop's own - * startup) are owned by the loop fiber and never need a handle. + * Holder-owned agent capability. Disposal stops and drains the loop and idle + * flushes before unregistering the agent, detaching its session, and unwinding + * its scoped context. Registry observers receive only the bare {@link Agent}. */ export interface AgentHandle { agent: Agent @@ -144,30 +87,15 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, emits `agent/session-start`, and only - * then starts the loop. The sequence is - * rollback-covered, but notifications delivered before a later listener - * failure remain observable; every agent or session creation announcement - * that began is paired by `agent/disposed` or `session/disposed` during - * rollback. The owner disposes the resolved handle to stop/drain, - * unregister, remove the session, and unwind the scope. - * The registry passes a context carrying the `create()` caller's fiber and - * scope as `ownerCtx`. The implementation attaches the unpublished - * transaction and resulting lifecycle to that owner; it must not infer - * ownership from the factory object's registration context. + * Create, compose, publish, announce, and start an agent under the caller's + * ownership. Rollback pairs any creation announcement that began. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** - * Load a persisted session and resume an agent on it. Async because it awaits - * both `ctx.sessionPersistence.load` and the optional unpublished setup - * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * Load, compose, publish, announce, and resume an agent under caller ownership. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -207,41 +135,25 @@ export class AgentRegistry extends Service { constructor(ctx: Context) { super(ctx, 'agents') - // The `ctx.agent` DX accessor: default `undefined` on every context, so a - // plain plugin context reads cleanly instead of hitting the Cordis - // unknown-property throw. Each Agent.ctx shadows it with an own property - // (own properties resolve before the context proxy is consulted), so the - // accessor body never needs to resolve a scope itself. Effect-scoped: - // unwinds with this service's fiber. + // Agent contexts shadow this plain-context default with an own property. ctx.accessor('agent', { get: () => undefined }) } /** - * Register the agent-creation factory (the loop calls this on construction, - * effect-scoped). A traced Cordis service is canonicalized to its concrete - * target; each create/resume call is then traced through that caller's - * context so ownership follows the caller without stacking proxy layers. - * Throws if a factory is already registered. Returns the disposer; on - * dispose the factory slot is cleared. + * Register the effect-scoped creation factory, rejecting a duplicate. Service + * factories are retraced through each create/resume caller for ownership. * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. - * @returns the disposer that clears the factory slot. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * @returns the exact Cordis effect disposer. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { if (this.factory !== undefined) throw new Error('an agent factory is already registered') - // Avoid stacking two Cordis shadow layers when a caller passes a Service - // already read through a context. Calls are re-traced through their - // actual owner context below. + // Store the concrete service; calls are retraced through their owner. const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory this.factory = { target } return () => { this.factory = undefined } }, 'agents.setFactory()') - // The exact cordis effect disposer (the agents.register() convention): a - // caller's composite effect can yield it for in-order teardown; the - // loop's constructor effect returns it directly, identity-nesting the - // registration under that effect. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -253,20 +165,13 @@ export class AgentRegistry extends Service { } /** - * Create and publish a new agent through the registered factory. - * Distinct from {@link register} (which records an already-constructed - * agent): this constructs the agent and its session. Rejects if no factory is - * registered or creation/setup fails. The resolved {@link AgentHandle} lets - * the owner tear down exactly this agent. + * Create and publish an owned agent and session through the active factory. * @param options - agent id, session id/seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { const ownerCtx = this.ctx - // Re-trace a Service-backed factory through the accessing context - // explicitly. This preserves AgentLoop's dependency origin while binding - // its effects to ownerCtx; plain factories receive ownerCtx as an explicit - // capability and need no Cordis tracker magic. + // Bind service effects to this caller while preserving factory dependencies. const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver @@ -289,22 +194,10 @@ export class AgentRegistry extends Service { } /** - * Register a live agent. Throws if an agent with the same id is already - * registered. Emits `agent/created` on registration and `agent/disposed` - * when the calling fiber is disposed — both with the agent's scope carrier - * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the - * emits are scope-filtered regardless of which context invoked `register` - * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * Register a live agent in the calling effect scope, with scope-filtered + * creation and disposal events. Duplicate ids throw. * @param agent - the already-constructed agent to record in the store. - * @returns the EXACT Cordis effect disposer (single-shot; a repeat call - * returns undefined without awaiting an in-flight teardown). Exact - * identity is load-bearing: a composite (generator) effect that owns a - * teardown ORDER — the agent factory's lifecycle chain — must yield THIS - * function so Cordis nests the unregistration at that yield position; - * yielding a wrapper would leave it disposing as a concurrent sibling on - * owner unload, unregistering the agent (and emitting `agent/disposed`) - * while its final turn is still draining. + * @returns the exact Cordis effect disposer for nested teardown ordering. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { @@ -316,22 +209,14 @@ export class AgentRegistry extends Service { } /** - * Insert an already-constructed agent without announcing it. This is the - * advanced ordered-lifecycle primitive used by the async agent factory: it - * first completes setup while the agent is unpublished, then assigns the - * returned detach closure into its pre-installed composite teardown before - * calling {@link announce}. Ordinary callers use {@link register}. + * Insert an unpublished agent for an ordered factory transaction. * @param agent - the prepared, unpublished agent. - * @returns an idempotent closure that removes this exact entry and emits - * `agent/disposed` with listener failures contained. When called from a - * synchronous `agent/created` listener, removal and disposal wait until - * that creation dispatch unwinds. + * @returns an idempotent detach closure; during creation dispatch it defers. */ enter(agent: Agent): () => void { const id = agent.id const carrier = scopeTarget(agent, agent) - // This is the authoritative collision boundary. Concurrent create/resume - // operations may both prepare, but only one exact entry can publish. + // Prepared transactions arbitrate identity at this publication boundary. if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) const entry: AgentEntry = { id, @@ -347,11 +232,7 @@ export class AgentRegistry extends Service { const detach = (): void => { if (!entered) return entered = false - // Every callback reached by this creation dispatch must observe the same - // live entry, and disposal must follow creation. A listener may own - // the advanced detach capability, so make that ordering structural: - // visibility and the paired disposal are deferred until announce()'s - // synchronous dispatch has unwound. + // Creation listeners observe one stable entry before paired disposal. if (entry.announcing) { entry.detachRequested = true return diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 7a046dcc82..3341e3499b 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -1,46 +1,6 @@ /** - * Agent interface and event taxonomy. Every plugin programs against the - * `Agent` handle defined here; the concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop`. - * - * Merge-extensible: `AgentOptions` supports declaration merging for - * plugin-specific creation options. - * - * ## Event-domain semantics (the boundary rule) - * - * The harness has three event domains, each with one job: - * - * - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT - * log. Owns `SessionEventMap`; every entry is JSON-only (no live objects). - * One `session/event` emit per append, plus the `session/flush` parallel - * durability checkpoint. Answers "what happened, durably/replayably." A - * consumer that wants the live transcript subscribes here. - * - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the - * live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/ - * `agent/request`/`agent/session-prefix`/`agent/step-result`/ - * `agent/turn-continuation` waterfalls and the serial `agent/pre-step` / - * `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits - * (`agent/status`, `agent/error`, `agent/created`/ - * `agent/disposed`, `agent/queued`, `agent/session-start`) - * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — - * they are durable `session/event` records. Answers "right now, with the agent - * object — intercept or observe." - * - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution. - * - * **The rule:** a durable, replayable fact is a SessionEvent; a live - * interception or a transient/live-object signal is an `agent`/`tools` Cordis - * event. A turn/step boundary is a durable fact: it lives in the session log - * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` - * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary - * keeps a session-id→agent map from `agent/created`/`agent/disposed`. - * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` - * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. - * - * The interception waterfalls here (`agent/prompt-submit`, `agent/request`, - * `agent/step-result`, `agent/turn-continuation`) each return a typed Decision; - * the terminal serial `agent/turn-stop` returns the stop-only subset. The - * convention is pinned by - * `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`. + * Public agent types and live-runtime events. Durable transcript facts and + * turn/step boundaries remain `@deepseek-ai/dsh-session` events. * * @module @deepseek-ai/dsh-agent/types */ @@ -66,38 +26,18 @@ import type { Session } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { - /** - * The agent this assembly is for. The agent loop passes it on every - * per-step assembly (via its `assembleContextFor(agent)` helper, which - * also sets the `scope` field to the same agent — the layer selector - * `dsh-system-prompt` reads); variable providers project per-agent facts - * from it (`options.model` → `{{model}}`, `session.header.cwd` → - * `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics) - * has no agent — providers must tolerate its absence. Never set `agent` - * without `scope`: the assembly would silently miss the agent's scoped - * sections/tools (the dev invariants flag it). - */ + /** Agent for this assembly; absent on unscoped diagnostic assemblies. */ agent?: Agent } } -/** - * Options an agent is created with. The persona is NOT here: the - * dsh-system-prompt config supplies the global default, and a scoped - * `deployment:persona` section may override it for one agent. - * Merge-extensible: plugins declare extra fields via declaration merging. - */ +/** Merge-extensible agent creation options. Persona belongs to system-prompt sections. */ export interface AgentOptions { /** Model name (must have a registered adapter at call time). */ model?: string } -/** - * Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An - * absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content - * must label itself here or its message is recorded as a user prompt (see - * {@link HookContext} on why that label is load-bearing). - */ +/** Message options; an omitted source resolves to `{ kind: 'user' }`. */ export interface SendOptions { source?: MessageSource } @@ -110,54 +50,22 @@ export interface SendOptions { */ export type AgentStatus = 'idle' | 'running' | 'disposed' -/** - * Model-facing context an interception listener wants the agent to SEE on the - * next request — the canonical shape behind every "inject extra context" - * decision ({@link PromptDecision}, {@link PostToolDecision}, - * {@link ContinuationDecision}). It is `agent.inject()`ed as a - * `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()` - * defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin - * context as a user prompt and corrupt derived history. A bridge sets - * `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not - * optional — the label is load-bearing, never defaulted here. - */ +/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ export interface HookContext { content: ContentBlock[] source: MessageSource } /** - * The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns - * for ONE drained queued message, before it becomes a `user/message`. Maps onto - * the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`. - * - * - `allow` proceeds with the prompt; optional `content` REPLACES the prompt - * bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a - * separate `context/message` the next request also sees. - * - `block` drops the prompt (it never becomes a `user/message`); `reason` is - * the durable record of why. The loop appends a `prompt/blocked` session event - * (carrying the original content, source, and `reason`) in place of the - * dropped `user/message`, so the veto survives replay even in a MIXED batch - * where a sibling prompt is allowed. A batch whose EVERY prompt is blocked - * additionally opens a zero-step turn that ends with {@link TurnEndReason} - * `rejected` (so the boundary stays balanced and a UI can render "blocked by - * hook"). + * Prompt interception result. `allow.content` replaces the prompt and + * `additionalContext` becomes a separate context message. `block` records a + * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. */ export type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; reason: string } -/** - * The decision an {@link Agent} `agent/turn-continuation` waterfall listener - * returns. The loop computes the default (`continue` when the step had tool - * calls or steering was injected, else `stop`); listeners override it to - * force-continue (`/goal`, `/loop`) or force-stop (budget guards). - * - * A `continue` may carry a `reason`: model-facing context recorded as next-STEP - * steering within the SAME turn (the loop enqueues it through the steering - * channel, so the continued turn's next step sees it). This is the typed twin of - * the existing "steer from a step/end listener" `/goal` pattern. - */ +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ export type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: HookContext } @@ -169,48 +77,19 @@ export type ContinuationDecision = */ export type ContinuationStop = Extract -/** - * Why an agent's session lifecycle began, carried by `agent/session-start`. A - * bridge keys its SessionStart hook's matcher on this (Claude Code's - * `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create - * (including a seeded/forked create — a seed is NOT a resume); `resume` = a - * persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are - * driven by those subsystems (compact = `TODO(compaction)`). - */ +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' -/** - * The agent handle — the surface every plugin (UI, hooks, orchestrators) - * programs against. The concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop - * package should depend on the implementation. - */ +/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */ export interface Agent { readonly id: AgentId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent). - * Registrations through it — tools, prompt sections/variables, event - * listeners, restrictions — are visible to THIS agent only and unwind when - * the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for - * this agent's dispatches (zero self-filtering). Service resolution through - * it flows through the loop plugin's dependency surface — handing out - * `agent.ctx` hands out that capability. Live for exactly the agent's - * lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT. - */ + /** Agent-scoped context; its contributions are agent-local and unwind on disposal. */ readonly ctx: Context - /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. - */ + /** Queue detached, frozen lossless-JSON input; starts a turn when idle. */ send(content: ContentBlock[], options?: SendOptions): void /** @@ -221,317 +100,113 @@ export interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as tagged synthetic - * context rather than a user prompt. Does not run the model. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * from this synchronous method, but lifecycle disposal awaits it before - * unregistering the agent or detaching its session. A failing flush is - * reported via `agent/error` (step `0`) and the logger, never thrown into the - * caller. - * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. + * Append model-facing context without running the model. Idle injection uses + * a one-shot turn and durability checkpoint; disposal awaits that checkpoint, + * and flush failures are reported through `agent/error`. */ inject(content: ContentBlock[], options?: SendOptions): void - /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. - */ + /** Clear queued and steering work and abort the active step; idle cancellation is a no-op. */ cancel(reason?: string): void - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. - */ + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. } declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- /** - * An agent's fully composed scoped world was published in the - * {@link AgentRegistry}. Its session is already live in the session store. - * Setup is composition-only by contract; the subsequent - * `agent/session-start` boundary is the first supported place to inject or - * queue startup work. A synchronous listener throw - * vetoes publication and rollback emits the matching disposal edges; - * returned-promise rejection is observed and logged but cannot - * retroactively veto this synchronous boundary. A synchronous listener - * that requests the advanced registry detach does not remove the entry - * immediately: removal and the paired `agent/disposed` edge wait until the - * creation dispatch unwinds, so no later creation listener observes a - * disposal that preceded its own creation callback. + * A fully configured agent and its session were published. Synchronous + * listener failure vetoes publication; asynchronous failure is reported. * @param agent - the newly registered agent with its live session and completed setup. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/created'(this: Scoped, agent: Agent): void /** - * An agent was removed from the registry. The concrete AgentLoop lifecycle - * emits this only after its driver and any in-flight turn reach quiescence; - * a custom agent registered through the public registry owns its own driver - * contract, which the registry cannot infer. Ordered teardown may still be - * detaching the session and unwinding scoped registrations when this runs. + * An agent left the registry. AgentLoop emits this after driver quiescence; + * custom registry users own their driver-ordering contract. * @param agent - the exact agent removed from the registry. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive - * lifecycle off this transition, never off a status you just requested — - * `send()` does not flip status to `running` before it returns. + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * A message entered the agent's inbox (queued or steering). Content and the - * resolved source are the detached, deeply-frozen values retained by the - * inbox. `source` has defaults applied and is not the caller's raw options. + * Detached, frozen content entered the agent's inbox. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. * @param info - the accepted source plus whether it entered as steering. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void // ---- session lifecycle (emit) ---- /** - * The agent's session lifecycle began, fired once before its first turn. - * `source` says why ({@link SessionStartSource}: fresh startup, a resumed - * persisted session, …). A pure NOTIFICATION (emit, not waterfall): a - * listener cannot veto by returning a decision or throwing. A listener that - * wants to seed context does so via `agent.inject()` (a `context/message` the - * first request sees). A lifecycle owner can still dispose its structural - * ownership edge during this notification; publication rechecks liveness and - * then aborts before the driver starts. + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. * @param agent - the agent whose session lifecycle began. * @param source - why the session started (fresh startup, resume, …). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void - // Turn and step boundaries are NOT mirrored as agent/* emits: a consumer - // that needs them reads the durable `turn/start`/`turn/end`/`step/start`/ - // `step/end` session events off the `session/event` feed (the session log is - // the live transcript feed). See the module doc's three-domain rule and the - // "remove agent boundary mirror events" RFC. + // Turn and step boundaries are durable session events, not agent events. // ---- step/request extension seams (serial + waterfall) ---- /** - * Awaited pre-step surface-mutation checkpoint, fired once per step AFTER - * `turn/start` (and after the prior step closed) but BEFORE this step's - * `step/start` — so anything a listener appends lands OUTSIDE the step, - * between `turn/start`/`step/end` and the upcoming `step/start`. `step` is - * the number of the step about to start. The loop awaits - * `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then - * opens the step and derives the request history ONCE from whatever the - * surface now holds. This is where compaction belongs: it mutates the session - * surface in place (shadowing an older range with a summary node) with its - * log-only `compact/*` records cleanly outside any step, and the single - * subsequent derive reflects the mutation — so there is no double-derive and - * no listener can see (or be expected to act on) an assembled `messages` - * array that does not exist yet. - * - * Serial (awaited in registration order), not a waterfall: a listener - * mutates the surface as a side effect; there is nothing to transform, but - * the loop must wait for the mutation to complete before opening the step - * and deriving. Cordis `serial` bails early if a listener returns a bail - * value; this event is typed and documented as `void`, so listeners must not - * return a semantic veto value. `fullSystemPrompt` is the assembled prompt a - * listener needs to measure pressure (the system prompt counts toward the - * budget), and `sessionPrefix` is the instance's composed - * {@link agent/session-prefix} product for the same reason — every request - * carries it in front of the derived history, and it is composed BEFORE - * this seam fires precisely so a pressure gate counts the prefix the - * request will actually send (never a stale logged one). `signal` cancels - * any in-flight work a listener starts (e.g. a - * summarization model call). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. - * @param agent - the agent about to open the step. - * @param turn - the already-open turn this step belongs to. - * @param step - the number of the step about to start. - * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. - * @param sessionPrefix - the instance's frozen session prefix, for the same measurement. - * @param signal - aborts in-flight listener work when the turn is torn down. + * Awaited checkpoint before `step/start` for outside-step surface mutations. + * 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: `fullSystemPrompt`/`sessionPrefix` are a smell on a generic - // per-step seam — compaction - // is their only consumer, so a wide event carries payloads just one listener - // reads. Revisit if no second consumer appears: e.g. hand listeners a lazy - // prompt provider, or move token-pressure measurement behind a - // compaction-specific seam instead of the shared pre-step checkpoint. + // 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 /** - * Waterfall: decide what happens to ONE drained queued message before it - * becomes a `user/message` — allow (optionally rewriting the prompt bytes or - * attaching `additionalContext`) or block it. Fires inside the already-open - * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. - * Call `next()` to delegate to the default (allow unchanged), or return a - * {@link PromptDecision} without calling `next()` to short-circuit. + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. * @param agent - the agent draining its inbox. * @param content - the drained message's blocks, as queued. * @param source - the message's resolved source. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise /** - * Waterfall: shape the step's call configuration — model switching, - * sampling overrides — by returning a replacement {@link LlmCallConfig} - * (the frozen seed is the config the loop would otherwise use). Config is - * ALL a listener shapes here: every request is a pure function of the - * session log (the reconstructability RFC), so model-visible content - * flows through the log channels — `inject()`, steering, prompt-submit - * `additionalContext`, prompt sections via `system-prompt/assemble`, or - * the header-logged session prefix via {@link agent/session-prefix} - * — never through request mutation, and the loop records whatever config - * the request actually uses as a `request/header*` event before dispatch. - * The step's messages are already snapshotted when this fires (the - * `step/start` boundary): an `inject()` from a listener here lands in the - * log but joins the NEXT request. For surface mutation that must precede - * the snapshot (compaction), use {@link agent/pre-step}. Call `next()` to - * delegate, or return an {@link LlmCallConfig} without it to - * short-circuit. + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. * @param agent - the agent making the model call. * @param turn - the open turn number. * @param step - the step whose request this is. * @param config - the config the loop would use (frozen); return a replacement to switch. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise /** - * Waterfall: compose the SESSION PREFIX — request-only messages placed in - * front of the ENTIRE derived history (directly after the provider's - * system slot) on every request this loop instance sends. Fired ONCE per - * loop instance, lazily before its first step's {@link agent/pre-step} - * seam — BEFORE the pre-step so a token-pressure gate (compaction) counts - * the prefix this instance will actually send, never a previous - * instance's logged one. The composed - * result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the - * instance's anchoring `'initial'`/`'resume'` header snapshot, and reused - * verbatim for every subsequent request — never recomputed mid-session, - * so the provider prefix cache holds by construction (a process restart - * or `ctx.agents.resume()` is a new instance: it recomposes, and any - * drift lands attributably on the `'resume'` snapshot). Composition runs - * outside the step, before the boundary snapshot: a composing listener's - * session append joins the CURRENT request's derived history. A - * composition interrupted by a cancel/dispose landing inside the - * waterfall is discarded — never cached, logged, or sent — and the next - * turn recomposes under a live signal, so an abort-aware listener's - * degraded fallback cannot leak into later requests. - * - * This is the home for session-stable openers the model must always see - * but that must NOT become durable history — a skills catalog, an - * AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` - * never returns the prefix, and the header events are its only durable - * record, so the request stays reconstructable from the log. Content - * that CHANGES mid-session belongs in the append-only history channels - * instead — `agent.inject()`, a `tools/post-execute` decision's - * `additionalContext`, prompt-submit `additionalContext` — each a - * durable `context/message` paid once and prefix-cached thereafter. - * - * The seed is a frozen empty list; a contributing listener returns a NEW - * array — never an in-place push. The canonical contribution is a - * PREPEND, `[mine, ...await next()]`: the waterfall unwinds - * innermost-first (the LAST-registered listener's `next()` resolves - * first), so prepending yields registration order on the wire, and every - * plugin using it composes deterministically. The append form - * `[...await next(), mine]` is legal but places a contribution AFTER - * every later-registered plugin's — reverse registration order when all - * contributors append. Call `next()` to - * delegate, or return a list without it to short-circuit. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Compose the frozen session-stable request prefix once per loop instance. + * Interrupted composition is discarded; changing context belongs in history. + * 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 empty seed; return an extended replacement to contribute. - * @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. * @mode waterfall */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise @@ -542,47 +217,26 @@ declare module 'cordis' { * @param turn - the open turn number. * @param step - the step that produced the message. * @param message - the assistant message as assembled from the stream. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise /** - * Waterfall: override the turn-continuation decision via a typed - * {@link ContinuationDecision}. The loop's `defaultDecision` is `continue` - * when the step had tool calls or steering was injected, else `stop`. - * Listeners force-continue (`/goal`, `/loop` — optionally attaching a - * `reason` recorded as next-step steering) or force-stop (budget guards). - * Call `next()` to delegate to the default, or return a decision to override. + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. * @param agent - the agent deciding whether to run another step. * @param turn - the turn being continued or stopped. * @param defaultDecision - what the loop would do absent an override. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise /** - * Serial terminal-stop checkpoint after the ordinary - * `agent/turn-continuation` waterfall, any `continue.reason`, and the - * pending-steering continuation override have been folded. A listener - * returns `{ action: 'stop' }` to make this turn terminal, or `undefined` - * to abstain. Terminal stop is monotonic: listener order and steering - * cannot resume the turn, and pending steering is discarded rather than - * becoming another step or turn. + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded. A stop discards pending steering. * @param agent - the agent whose composed continuation outcome may be stopped. * @param turn - the turn at its terminal-stop checkpoint. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode serial */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined @@ -595,11 +249,7 @@ declare module 'cordis' { * @param turn - the turn in which the failure surfaced. * @param step - the step at which the failure surfaced. * @param error - the failure, verbatim. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered - * through `agent.ctx` fires only for that agent's dispatches; a listener on a - * plain plugin context fires for every agent. The dispatch `this` is the - * scope carrier (`Scoped`), built by the emitting side via - * `scopeTarget`/`agentEvents`. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index 55a0591d0e..a221628f85 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -1,6 +1,6 @@ # dsh-scope -Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle. +Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis context whose backing fiber owns every registration made through it. `scopeOf(ctx)` reads the tag, and `scopeTarget(base, key)` routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents. ## Public API @@ -15,6 +15,6 @@ Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis co ## Design contract -Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the [agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals) for rationale and security non-goals. Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve. diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index e229243504..76b8236885 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -73,15 +73,11 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { } /** - * Build the routing receiver for a scope-filtered event. Untagged listeners - * remain global; tagged listeners run only when their key matches. A base - * Cordis filter is composed before the scope predicate. - * - * The receiver is deliberately opaque: listener code obtains the real subject - * from event arguments, never from `this`. + * Build an opaque receiver that preserves the base filter, admits untagged + * listeners globally, and admits tagged listeners only for a matching key. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. - * @returns an opaque dispatch carrier. + * @returns a carrier whose subject remains available only through event arguments. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 97b18b0504..2ebbd0390c 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,35 +8,35 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber. -- `ctx.sessions.flush(session: Session): Promise` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier. +- `ctx.sessions.create(id?, options?)` validates and detaches durable seed/header data, publishes the session, and binds it to the calling fiber. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. It rejects unpublished, detached, or stale objects. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` #### Advanced: ordered-teardown lifecycle primitives -`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect: +Use the split lifecycle only when teardown must be ordered with another resource: -- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`. -- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds. -- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge. +- `prepare(id?, options?)` constructs without publication. +- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. +- `announce(session)` emits the single creation edge. Detach during that dispatch is deferred and later emits the paired disposal edge. -`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload. +`dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). ### Live service events -The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). +The store pairs announced creation with disposal, publishes post-commit append notifications with per-listener containment, and provides an awaited durability checkpoint. Exact signatures and scope behavior live in the generated [event catalog](../../../docs/cordis-catalog/events.md); payloads live in the [persistence catalog](../../../docs/persistence-catalog.md). ### Class: `Session` Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs. -- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. -- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). -- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. -- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. +- `session.append(type, data, opts?)` snapshots and freezes durable data, commits synchronously, then notifies observers with failure containment. Reentrant attached-session appends reject. +- `session.deriveMessages()` incrementally projects the derived surface and returns a fresh array over frozen messages. +- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. +- `session.surface` lazily folds new `surfaceOp` markers; `replaceGeneration` changes on rewrites. +- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -53,7 +53,7 @@ Durable values need one accepted representation, not a check followed by a secon ### Request-header reconstruction (`request-header.ts`) -The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. +`request/header` and `request/header-delta` make the non-history request envelope reconstructable from the log. `foldRequestHeader()` reconstructs the active header, `diffHeader()` encodes changes, and `applyHeaderDelta()` replays them; unsupported deltas fall back to a full snapshot. `messagePrefix` remains separate from derived history. See the [reconstructable-requests RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). ### Session event vocabulary (`types.ts`) @@ -75,7 +75,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Extension points - Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log. -- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through. +- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage. - Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. ### What is NOT here (TODO) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index a6dff5cd27..5b01c38fe6 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -34,67 +34,35 @@ declare module 'cordis' { interface Events { /** - * A session was created in the store. A synchronous listener throw vetoes - * publication and rollback emits the matching `session/disposed` edge; - * returned-promise rejection is observed and logged but cannot retroactively - * veto this synchronous boundary. A synchronous listener that requests the - * advanced detach does not remove the entry immediately: removal and the - * paired `session/disposed` edge wait until the creation dispatch unwinds. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the - * session's owner scope, captured when the session was ENTERED (an agent's - * session is entered through `agent.ctx`, so its events dispatch in that - * agent's scope; a bare `sessions.create()` from a plain plugin dispatches - * subject-less). A listener registered through `agent.ctx` hears only that - * agent's sessions; a plain plugin listener hears every session. + * Emitted after session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only sessions entered through that agent's context. * @param session - the session just entered and announced. * @mode emit */ 'session/created'(this: Scoped, session: Session): void /** - * A previously announced session left the store. Emitted exactly once on - * normal detach or publication rollback, and never for a prepared/entered - * session whose `session/created` announcement did not begin. Listener - * failures (including returned-promise rejections) are logged and contained - * per listener so teardown always reaches quiescence. - * Scope-filtered dispatch uses the same owner carrier captured at entry; - * agent-scoped listeners hear only their own session's teardown. + * Emitted once when an announced session leaves the store, including + * publication rollback. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. * @param session - the session that is no longer live in the store. * @mode emit */ 'session/disposed'(this: Scoped, session: Session): void /** - * An event was appended to a session log (sync, fire-and-forget). This is - * the per-append feed a UI or invariant plugin tails. The log push is the - * commit point; synchronous throws and returned-promise rejections from - * observers are logged and contained per listener, so they cannot make a - * committed append appear to fail or starve later listeners. The exact - * callback list and Cordis internal-dispatch checks resolve before the push; - * callbacks themselves run only after it. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the - * session's owner scope, captured when the session was ENTERED (an agent's - * session is entered through `agent.ctx`, so its events dispatch in that - * agent's scope; a bare `sessions.create()` from a plain plugin dispatches - * subject-less). A listener registered through `agent.ctx` hears only that - * agent's sessions; a plain plugin listener hears every session. + * Post-commit append feed. Observer failures are logged and contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only events from sessions entered through that agent's context. * @param session - the session whose log grew. * @param event - the appended event, exactly as recorded. * @mode emit */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** - * Awaited durability checkpoint. The agent loop awaits - * `ctx.sessions.flush(session)` at every turn end; persistence - * plugins (JSONL, SQLite) drain their write-behind buffers here and on - * fiber dispose. Awaited (parallel), not a waterfall: every listener runs - * and the caller waits for all of them, but none can veto. Dispatch it - * through {@link SessionStore.flush} — the store owns the carrier — never - * via a raw `ctx.parallel`. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the - * session's owner scope, captured when the session was ENTERED (an agent's - * session is entered through `agent.ctx`, so its events dispatch in that - * agent's scope; a bare `sessions.create()` from a plain plugin dispatches - * subject-less). A listener registered through `agent.ctx` hears only that - * agent's sessions; a plain plugin listener hears every session. + * Awaited parallel durability checkpoint; dispatch through + * {@link SessionStore.flush}. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ @@ -102,15 +70,7 @@ declare module 'cordis' { } } -/** - * Renders a `context/message` or `steering/message` event as a tagged - * synthetic user-role message (the system-reminder pattern: zero adapter - * burden, models distinguish it from real user prompts by the envelope). - * - * Live-adapter review has validated the tagged-envelope rendering against - * current DeepSeek behavior; provider-specific mismatches belong in that - * adapter, not in the canonical session vocabulary. - */ +/** Render injected context as a tagged synthetic user-role message. */ function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] { const open = `<${tag} source=${JSON.stringify(source.kind)}>` const close = `` diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 2ec36087dd..28c75112b6 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -1,17 +1,4 @@ -/** - * Lossless-JSON validation and snapshot materialization for session data. - * - * The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every - * `event.data` must round-trip losslessly through JSON so any persistence - * backend can store and reload it byte-identically. This invariant belongs to - * the log itself — `Session.append` enforces it at the source, so a - * non-serializable event never enters `session.events` and the live log can - * never diverge from what a backend can persist. Other public boundaries use - * {@link snapshotJsonValue} when they must validate and detach in one pass; - * {@link isJsonValue} remains the non-copying structural predicate. - * - * @module @deepseek-ai/dsh-session/json - */ +/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */ /** * A value that round-trips losslessly through JSON: `null`, a boolean, a finite @@ -25,19 +12,9 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } /** - * Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass. - * Each array slot or own enumerable string-keyed object value is read exactly - * once, validated, and copied immediately. This is intentionally not - * `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter - * could return plain JSON to the check and an exotic class instance to the - * clone, whose prototype `structuredClone` would erase before a later check. - * - * Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use - * the ordinary `Array.prototype` (subclass instances are not plain JSON - * containers), while null-prototype objects are accepted and normalized to - * ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite - * numbers, unsupported scalar types, and exotic object or array shells return - * `undefined`. A throwing getter is a caller failure and propagates unchanged. + * Validate and detach lossless JSON in one read per property. Accepts ordinary + * arrays, plain or null-prototype objects, and JSON scalars; rejects sparse, + * cyclic, exotic, negative-zero, and non-finite values. Getter throws propagate. * * @param value - the candidate value to validate and detach. * @returns the detached snapshot, or `undefined` when the value is not @@ -104,28 +81,12 @@ export function snapshotJsonValue(value: T): T | undefined { } /** - * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers - * other than negative zero, booleans, strings, plain arrays, and plain objects - * of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which - * JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns - * into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) — - * anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse - * arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not - * round-trip. Detects circular references (which would throw) and reports them - * as non-serializable rather than propagating the throw. - * - * Scope — this is a structural plain-data predicate, not an invocation of - * `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are - * inspected (`Object.values`). Symbol-keyed and non-enumerable properties are - * omitted from the durable data surface. Custom `toJSON` behavior is not - * executed; boundaries that persist a value first materialize a new plain-data - * record with {@link snapshotJsonValue}. Getters are invoked during this check, - * so callers that need a stable detached value use that one-pass materializer - * instead of checking and then rereading a side-effecting record. + * Test the same lossless JSON boundary as {@link snapshotJsonValue} without + * detaching it. Only own enumerable string properties participate; `toJSON` + * is ignored and getters run, so persistence boundaries use the snapshotter. * @param value - the candidate event data to test. - * @param seen - objects on the current descent path, for circular-reference - * detection; the recursion threads it — callers omit it. - * @returns true when `value` survives a JSON round-trip losslessly. + * @param seen - current recursion path; callers omit it. + * @returns whether `value` survives JSON round-trip losslessly. */ export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { if (value === null) return true diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index c320ccaa6a..3a6600ce6e 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -7,10 +7,8 @@ import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** - * Scan `events` for an open turn/step at the tail and return the synthetic boundary events - * that close them, with `seq` continuing the log and `time` copied from the last real event - * (the closers stand in for the crash moment; reusing the last timestamp keeps them - * deterministic and never invents a "future" time). + * Return deterministic synthetic events that close an open tail turn or step. + * Sequences continue the log and timestamps reuse the last real event. * * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail). * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced. diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7219856bdb..918a274ead 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -23,12 +23,8 @@ const SURFACE_EVENT_TYPES = new Set([ ]) /** - * Whether an event's `type` is surface-eligible (one of the five - * message-producing {@link SurfaceEventType} values). This is the TYPE check - * only — it does NOT require `surfaceOp` to be present. Use it to detect a - * surface-eligible event that is MISSING its mandatory marker (e.g. validating - * a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed - * {@link SurfaceEvent} with `surfaceOp` present. + * Check only whether a type may enter the message surface. Use + * {@link isSurfaceEvent} when the mandatory `surfaceOp` must also be present. * @param type - the event type string to test. * @returns true when the type is one of the five message-producing types. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 4e64dc6b51..2acbc40780 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -21,16 +21,7 @@ export function SessionId(id: string): SessionId { export const SESSION_FORMAT_VERSION = 0 /** - * Immutable session metadata — written once at creation and never rewritten. - * {@link Session} enforces that contract at runtime: it validates and detaches - * the accepted scalar fields, requires this header's id to match the session - * id, and deep-freezes the published record. - * - * Kept SEPARATE from the event log deliberately: format-version, cwd, and - * lineage are storage concerns, not conversation events, so they stay out of - * {@link SessionEventMap} and never reach `deriveMessages()`. Every reference - * system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail - * metadata) writes such a header. + * Immutable validated storage metadata, kept outside the conversation event log. */ export interface SessionHeader { /** @@ -63,17 +54,8 @@ export interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store reads this plain record and each accepted - * field once, then fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string @@ -119,13 +101,8 @@ export interface TurnEndReasonMap { disposed: { kind: 'disposed' } 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked every prompt before the first step. The zero-step turn still + * records a balanced durable boundary and the veto reason. */ rejected: { kind: 'rejected'; reason: string } /** @@ -157,15 +134,9 @@ export interface TodoItem { } /** - * The request header: everything about an LLM request besides its derived - * message history — the call configuration plus the rendered system prompt, - * tool schemas, and the session prefix. Logged session state (the - * reconstructability RFC): a - * {@link SessionEventMap} `request/header` snapshot installs one, a - * `request/header-delta` amends it, and folding those events over the log - * (`foldRequestHeader`) reconstructs the header any request was built under. - * Canonical form: an empty system prompt, an empty tool list, and an empty - * prefix are ABSENT fields, matching how requests are built. + * Logged request state outside derived history: call config, system prompt, + * tools, and session prefix. Header snapshots and deltas reconstruct it; + * canonical empty optional fields are absent. */ export interface EpochHeader { /** The conversation's call configuration (model + sampling scalars). */ @@ -356,16 +327,8 @@ export type SurfaceOp = | { op: 'replace'; start: number; end: number } /** - * Surface metadata passed to {@link Session.append}. - * `surfaceOp` controls how the event enters the surface linked list; - * `sourceEventSeqs` records the seq numbers of events that are provenance - * sources of this one (e.g. the `assistant/chunk` seqs behind an - * `assistant/message`, or the shadowed nodes behind a compaction replacement). - * - * Required for {@link SurfaceEventType} events — every message-producing event - * MUST declare how it enters the surface, because the surface is the sole - * source of derived history. Non-surface event types (`turn/start`, - * `assistant/chunk`, `error`, …) cannot carry surface metadata. + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. */ export interface SurfaceIntent { surfaceOp: SurfaceOp diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index dc1101faaf..6ff45d2875 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -1,6 +1,6 @@ # dsh-system-prompt -System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent. +System prompt assembly registry. Plugins contribute ordered sections, tool schemas, and named variables. The loop assembles once per step and renders the result as the complete model prompt. This plugin owns the static harness identity and global deployment persona; an agent-scoped persona shadows the global default. ## Config @@ -20,7 +20,7 @@ System prompt assembly registry. Plugins contribute ordered text sections, tool- ### Live events -`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md). +`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated [event catalog](../../../docs/cordis-catalog/events.md) owns signatures and dispatch contracts. ### Key types diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 63ba6b350d..739bfbf8df 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -1,13 +1,5 @@ /** - * System prompt assembly registry. Plugins contribute ordered text sections, - * tool schema providers, and named prompt variables; `assemble(context)` - * collates them through a waterfall that runs once per step, and `renderPrompt` - * interpolates `{{variable}}` references into the final text. - * - * The harness-owned prompt openers live here too: this plugin registers the - * static `harness:identity` section (order −100) and the deployment's - * `deployment:persona` section (order 0, from its `persona` config), so they - * exist for every agent regardless of which loop plugin drives it. + * Registry for ordered prompt sections, tool schemas, and prompt variables. * * @module @deepseek-ai/dsh-system-prompt */ @@ -25,58 +17,28 @@ declare module 'cordis' { interface Events { /** - * Waterfall around prompt assembly — mutate or extend the - * {@link PromptAssembly} (sections + tools + variables) before it is - * rendered. Bound to the {@link SystemPrompt} service; call `next()` to - * delegate. - * - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed - * by `context.scope` — a listener registered through `agent.ctx` fires only - * for that agent's assemblies; a plain plugin listener fires for every - * assembly (scope-less ones included, dispatched subject-less). - * - * The returned assembly is authoritative. This is an expert composition - * seam: a listener that removes or replaces another plugin's protocol - * contribution owns preserving that protocol's invariants. - * @param assembly - the assembly built from the registered sections, tool - * providers, and variable providers; listeners may mutate it or return a - * replacement. - * @param context - the per-assembly {@link AssembleContext} the caller - * passed to {@link SystemPrompt.assemble} (e.g. which agent the prompt - * is for), so a listener can filter or extend per agent. + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. * @mode waterfall */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise /** - * A section, tool provider, or variable provider was registered - * or unregistered (the assembly inputs changed — possibly for one scope - * only). An UNFILTERED registry-subject notification, deliberately not - * scope-filtered dispatch: a global change concerns every agent's next - * assembly, so a scoped listener subscribing here sees every change, not - * just its own scope's. + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. * @mode emit */ 'system-prompt/change'(): void } } -/** - * Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR. - * Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent` - * declares the `agent` field, so section text and variable providers can be - * functions of the calling agent. Every field is optional by nature: a bare - * `assemble()` (tests, diagnostics) carries an empty, scope-less context, and - * providers must tolerate absent fields. - */ +/** Merge-extensible context for one prompt assembly. */ export interface AssembleContext { /** - * The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped - * sections/variables/tool-providers registered through this key's context - * join the assembly (shadowing same-named global contributions), and the - * `system-prompt/assemble` waterfall dispatches in this scope. The agent - * loop sets it to the agent (alongside the `agent` DX field — never set - * `agent` without `scope`; the dev invariants flag the mismatch). Absent = - * a scope-less assembly: global layer only, subject-less dispatch. + * Scope whose providers and waterfall listeners participate. When absent, + * only global providers and subject-less listeners participate. */ scope?: ScopeKey } @@ -109,16 +71,7 @@ export interface AssembledSection { text: string } -/** - * What one tool-schema provider contributes to an assembly - * ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction - * visible set for the assembly's scope — exactly what the model may be shown. - * `knownNames` is its PRE-restriction name universe: the set configured names - * (`toolOrder`) are validated against, so a config typo fails loud while a - * restricted-away tool stays a normal, non-erroneous absence. Omitted, - * `knownNames` defaults to the names of `schemas` (right for providers with no - * restriction concept). - */ +/** Tool schemas visible in one assembly and their pre-restriction name set. */ export interface ToolProviderResult { /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] @@ -127,20 +80,8 @@ export interface ToolProviderResult { } /** - * The assembled prompt. - * - * Tool schemas are part of the assembly by design: "what the model is told it - * can do" is one coherent thing managed here, even though adapters transmit - * `tools` as a separate wire field rather than prompt text. They arrive in - * the canonical model-facing order (see {@link Config.toolOrder}). - * - * `variables` carries every registered prompt variable resolved against this - * assembly's context — key present means registered, `undefined` value means - * "no value for this assembly" (referencing it renders an error). Section - * texts are resolved but NOT yet interpolated; {@link renderPrompt} applies - * the variables, so waterfall listeners can still add sections or variables. - * - * Merge-extensible: plugins can declare extra fields on this interface. + * Merge-extensible assembled prompt. Sections remain uninterpolated until + * {@link renderPrompt}; tools are already in canonical model-facing order. */ export interface PromptAssembly { sections: AssembledSection[] @@ -154,22 +95,12 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/ /** A complete `{{...}}` reference group at the scan position (validated after). */ const GROUP_AT = /^\{\{([^{}]*)\}\}/ -/** - * The rest entry for {@link Config.toolOrder}: the position where registered - * tools not named in the list are inserted (in lexicographic name order). - * Reserved: collected tool schemas using this name are rejected before - * ordering, so the marker can never collide with a real model-facing tool. - */ +/** Reserved {@link Config.toolOrder} marker for unlisted tools. */ export const TOOL_ORDER_REST = '' /** - * Validate a configured tool-order list's shape at service construction: - * the {@link TOOL_ORDER_REST} rest entry exactly once, no duplicate names. - * Returns the list (or undefined when unconfigured); throws otherwise, - * failing the service at load — a bad order config must never reach an - * assembly. Whether every listed name matches a registered tool is checked - * at each assembly instead ({@link orderTools}): tool plugins register after - * this service constructs, so the tool set does not exist yet here. + * Validate duplicate names and the required {@link TOOL_ORDER_REST} marker. + * Registered names are checked later because plugins have not loaded yet. */ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefined { if (toolOrder === undefined) return undefined @@ -185,20 +116,9 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine } /** - * Order collected tool schemas by the validated policy: with no configured - * list, plain lexicographic name order; with one, listed names take their - * listed position and every unlisted tool lands at the - * {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed - * name outside `knownNames` — the providers' PRE-restriction name universe — - * throws: misconfiguration fails loud, and each assembly is the earliest - * moment the registered tool set exists to check against (tool plugins - * register after the service constructs, so load time is too early); the - * assembly rejects, failing the caller's turn before any model request. A - * listed name that is KNOWN but not collected (a tool restricted away for - * this assembly's scope) is a normal absence: its position simply - * contributes nothing — `toolOrder` stays compatible with per-agent - * `restrict()` masks. Never drops a collected tool, and both sorts are - * stable, so tools sharing a name keep their collection order. + * Apply configured tool order, inserting unlisted tools lexicographically at + * {@link TOOL_ORDER_REST}. Unknown configured names fail; known but restricted + * names may be absent. */ function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet): ToolSchema[] { const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST) @@ -224,62 +144,24 @@ function compareToolNames(a: ToolSchema, b: ToolSchema): number { /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */ export interface Config { /** - * The deployment's persona — the ONE deployment-authored fragment of the - * system prompt, rendered as the order-0 `deployment:persona` section - * (after the harness identity, before all tool guidance). Every agent in - * the context shares it by default; a per-agent persona is a SCOPED section - * of the same name registered through that agent's `agent.ctx` (it shadows - * this one for that agent — the subagent seam's `persona` request field does - * exactly that). Template, not free-form text: - * every complete `{{…}}` group is interpreted strictly against the - * registered prompt variables (the shipped agent loop registers `{{model}}` - * and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose - * yet (a deliberate deferral; see the prompt-variables RFC). Defaults to - * `''` — the empty section is dropped at render, so a persona-less - * deployment opens with the harness identity alone. + * Deployment-wide order-0 persona template. A scoped section named + * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string /** - * Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed - * tools take their listed position, and tools absent from the list are - * inserted at the {@link TOOL_ORDER_REST} (`''`) entry in - * lexicographic name order. A configured list must contain the rest entry - * exactly once, no duplicate names, and no name without a registered tool — - * a misconfigured order blocks work instead of silently reaching a model - * request: shape violations throw at load, and an unregistered name rejects - * every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may - * not be a collected tool name; such a provider output also rejects the - * assembly. The single assembly-time validation rejects either failure - * before any model request — the earliest moment the registered tool set - * exists to check against, since tool plugins register after this service - * constructs. When omitted, tools are ordered lexicographically by name. - * Applied to the tools - * {@link SystemPrompt.assemble} collects, BEFORE the - * `system-prompt/assemble` waterfall — like the sections' `order` sort, it - * canonicalizes what the registry contributed (registration order is a - * plugin-load artifact); a waterfall listener that mutates the tool list - * owns the determinism of what it emits. Rationale (and why not per-plugin - * weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md. + * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. + * Shape errors fail at load and unknown names fail at assembly. Omitted means + * lexicographic order. See the explicit-tool-order RFC for rationale. */ toolOrder?: string[] } /** - * Renders the text part of an assembly: interpolates `{{variable}}` - * references in each section from `assembly.variables`, drops empty sections, - * and joins the rest with blank lines. - * - * Strict by design (fail loud beats shipping a malformed prompt): a reference - * to an unregistered variable, to a registered variable with no value for - * this assembly, a complete `{{…}}` group that is not a well-formed variable - * name (e.g. `{{ model }}`), or a `{{` that does not open a complete group - * while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A - * lone `{{` with no `}}` anywhere after it is ordinary prose and passes - * through verbatim. Substituted values are never re-scanned. - * @param assembly - the assembly to render (typically the awaited result of - * {@link SystemPrompt.assemble}); only `sections` and `variables` are read. - * @returns the full system prompt text; `''` when every section renders empty - * (the caller then sends no system prompt at all). + * Interpolate strict `{{variable}}` references, drop empty sections, and join + * the rest with blank lines. Malformed, unknown, or undefined references throw; + * substituted values are not scanned again. + * @param assembly - the assembly whose sections and variables to render. + * @returns the rendered prompt, or `''` when all sections are empty. */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections @@ -296,10 +178,7 @@ function interpolate(section: AssembledSection, variables: Record= 0; open = text.indexOf('{{', last)) { const group = GROUP_AT.exec(text.slice(open)) if (group === null) { - // No complete simple group starts at this `{{`. A `}}` further on means - // a mangled reference (extra or nested braces) — fail loud. With no - // closing `}}` anywhere after, it is ordinary prose (shell, JSON) and - // passes through verbatim. + // A later closing brace makes this malformed; otherwise it is literal prose. if (text.indexOf('}}', open + 2) >= 0) { throw new Error(`malformed prompt variable reference at "${text.slice(open, open + 16)}…" in section "${section.name}" (references are complete simple {{name}} groups)`) } @@ -307,15 +186,12 @@ function interpolate(section: AssembledSection, variables: Record 0 ? known.join(', ') : '(none)'}`) @@ -330,22 +206,11 @@ function interpolate(section: AssembledSection, variables: Record = z.object({ persona: z.string().default(''), - // A schemastery array defaults to [] when omitted, but an omitted - // toolOrder must stay absent ("lexicographic order"), not become an - // explicitly-configured empty list (which is invalid — it lacks the - // rest entry). Forcing the default to undefined keeps the key out of the - // validated config; the cast is needed because .default() expects the - // array type. + // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -361,12 +226,7 @@ export class SystemPrompt extends Service { constructor(ctx: Context, public config: Config) { super(ctx, 'systemPrompt') this.toolOrder = validateToolOrder(config.toolOrder) - // The harness-owned openers. They live HERE (not on the loop plugin) so a - // deployment that swaps in a different loop keeps them: the identity is a - // harness fact stated ahead of everything, and the persona is the - // deployment's config, one section of the full prompt, never the whole. - // An empty persona still RESERVES the section name (one owner — a plugin - // re-registering it throws); renderPrompt drops the empty text. + // Keep harness-owned openers independent of the selected loop plugin. this.section({ name: 'harness:identity', order: -100, @@ -375,30 +235,17 @@ export class SystemPrompt extends Service { this.section({ name: 'deployment:persona', order: 0, - // The schema already defaulted an omitted persona to ''; the ?? only - // narrows the optional-input TYPE, it never supplies a different value. + // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', }) } /** - * Contribute a text section to the system prompt. Order is determined by - * `section.order` (ascending). The layer is decided by the CALLING context - * (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a - * scoped context (`agent.ctx`) contributes to that scope alone — and a - * scoped section SHADOWS a same-named global section for that scope's - * assemblies (most-specific-wins; this is how a per-agent persona overrides - * `deployment:persona`). The readonly typed contribution is borrowed until - * disposal; only the semantic - * finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a - * duplicate would silently double prompt text — e.g. a double-loaded tool - * plugin; the global-duplicate message names `agent.ctx` as the per-agent - * alternative). Removed when the calling fiber is disposed. Emits - * `system-prompt/change` on register/unregister. - * @param section - the section to contribute (name, order, text or provider). - * @returns the disposer that removes the section. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. */ section(section: PromptSection): () => void { if (!Number.isFinite(section.order)) { @@ -419,10 +266,7 @@ export class SystemPrompt extends Service { : `prompt section "${section.name}" is already registered in this scope`) } layer.push(section) - // Yield the rollback BEFORE emitting `system-prompt/change`: a generator - // effect collects each yielded disposer before the next step runs, so a - // throwing change listener removes the section instead of leaking it into - // every future assembly. + // Install rollback before notifying listeners that may throw. yield () => { const index = layer.indexOf(section) /* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */ @@ -432,31 +276,15 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.section()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * Contribute a tool-schema provider, evaluated at each assembly call with - * that assembly's {@link AssembleContext} (so it reflects the live registry - * state AND the assembly's scope — see {@link ToolProviderResult} for the - * `schemas`/`knownNames` split). The layer is decided by the calling - * context: a scoped provider (registered through `agent.ctx`) is consulted - * only for that scope's assemblies. Removed when the calling fiber is - * disposed. A provider must not return a schema named - * {@link TOOL_ORDER_REST}; that name is reserved for - * {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits - * `system-prompt/change`. - * @param provider - evaluated at every {@link assemble} for fresh schemas. - * @returns the disposer that removes the provider. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register a tool-schema provider in the calling context's scope. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { const scope = scopeOf(this.ctx) @@ -469,7 +297,7 @@ export class SystemPrompt extends Service { return created })() layer.push(provider) - // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + // Install rollback before notifying listeners that may throw. yield () => { const index = layer.indexOf(provider) /* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */ @@ -479,33 +307,17 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.tools()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * Contribute a named prompt variable, referenced from section text as - * `{{name}}`. The provider is evaluated at each assembly with that - * assembly's {@link AssembleContext}; returning `undefined` means "no value - * for this assembly" (a section referencing it then fails to render — a - * deployment must not claim facts it does not have). The layer is decided - * by the calling context: a scoped variable (registered through - * `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a - * same-named global variable there. Throws on a name that does not match - * `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered - * in the SAME layer. Removed when the calling fiber is disposed; emits - * `system-prompt/change` on register/unregister. - * @param name - the reference name (matches `[a-z][a-z0-9_]*`). - * @param provider - evaluated at every {@link assemble} for the value. - * @returns the disposer that removes the variable. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void { if (!VARIABLE_NAME.test(name)) { @@ -526,7 +338,7 @@ export class SystemPrompt extends Service { : `prompt variable "${name}" is already registered in this scope`) } layer.set(name, provider) - // Yield the rollback BEFORE emitting `system-prompt/change` (see section()). + // Install rollback before notifying listeners that may throw. yield () => { layer.delete(name) if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope) @@ -534,47 +346,21 @@ export class SystemPrompt extends Service { } this.ctx.emit('system-prompt/change') }.bind(this), 'systemPrompt.variable()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * Assemble the current prompt for one caller: the global layer merged with - * {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW - * same-named global ones — most-specific-wins) — section texts resolved - * against `context` and sorted by order across the union, tools collected - * from the global providers plus the scope's and put in the canonical - * model-facing order ({@link Config.toolOrder}, or lexicographic name order - * when unconfigured — provider registration order is a plugin-load artifact - * and never reaches the assembly; a configured order naming a tool outside - * the providers' `knownNames` universe rejects the assembly, while a known - * name restricted away for this scope is a normal absence), and every - * visible variable resolved against `context` into `assembly.variables`. - * Tool schemas are detached because assembly waterfalls may mutate them. - * Runs through the `system-prompt/assemble` waterfall, giving listeners the - * opportunity to mutate or replace the assembly; the returned value is the - * authoritative model-visible composition. Like the sections' `order` - * sort, tool canonicalization happens on the initial assembly; listener - * output owns its own determinism. Await the result before reading the - * assembly values — waterfall listeners may be async. - * Interpolation happens later, in {@link renderPrompt}. - * @param context - what this assembly is for (defaults to an empty context; - * see {@link AssembleContext}). - * @returns the assembly after the waterfall has run. + * Assemble global and scoped providers, apply canonical ordering, then run + * the assembly waterfall. Scoped sections and variables shadow globals. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. */ - // async so the misconfigured-toolOrder throw in orderTools surfaces as a - // rejection: a Promise-returning method must not throw synchronously - // (`assemble().catch(...)` would miss it). + // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { const scope = context.scope - // Variables: global layer first, then the scope's layer OVERWRITES - // same-named entries (shadowing — a per-agent value wins for that agent). + // Scoped variables shadow globals. const variables: Record = {} for (const [name, provider] of this.variableProviders) { variables[name] = provider(context) @@ -583,21 +369,13 @@ export class SystemPrompt extends Service { for (const [name, provider] of scopedVariables ?? []) { variables[name] = provider(context) } - // Sections: merge by name, scoped REPLACING same-named global entries - // (most-specific-wins — the per-agent persona mechanism), then sort by - // order across the union. Registration order within a layer is preserved - // for equal orders (stable sort). + // Scoped sections shadow globals before the stable order sort. const sectionByName = new Map() for (const section of this.sections) sectionByName.set(section.name, section) for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) { sectionByName.set(section.name, section) } - // Tools: consult the global providers plus the scope's, each with this - // assembly's context. `schemas` are what the model may see (already - // post-restriction, per provider); `knownNames` (defaulting to the - // schemas' names) form the pre-restriction universe `toolOrder` is - // validated against, so a restricted-away tool is a normal absence while - // a config typo still fails every assembly loudly. + // Validate order against pre-restriction names while collecting visible schemas. const providers = [ ...this.toolProviders, ...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [], diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a9411f6cba..89b53cee7c 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,16 +11,16 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry contributes the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); `both` contributes the visible native definitions and both infrastructure pieces. Restrictions cannot remove `run_code`, and registering, shadowing, or explicitly filtering that reserved name fails loudly. An expert `system-prompt/assemble` listener may replace any prompt or schema contribution; its returned assembly is authoritative, so the listener owns preserving Code Mode when the protocol should remain active. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and incompatible tool-order configuration rejects prompt assembly. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. -- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools; multiple masks intersect and scope-local tools merge afterwards. Unknown, local, or reserved names and empty filters reject. This is visibility composition, not an authority boundary; see the [scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec: ToolExecutionInput): Promise` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace. +- `ctx.tools.execute(exec)` snapshots arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, and snapshots the authoritative outcome before final observation. ### Injected services @@ -45,7 +45,10 @@ The live registry pipeline has three transformable waterfalls followed by the ob ### Extension points - Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically. -- `tools/pre-execute` is the reorderable allow/deny/ask gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch, while an `ask` resolves through the approval seam and dispatches only after a grant. Either non-grant path yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper. +- `tools/pre-execute` is the reorderable allow/deny/ask gate; `ctx.tools.guard()` adds monotonic owner policy after it. +- `tools/execute` wraps normalized core dispatch for timeout, retry, or metrics. Wrappers may replace only the operational signal. +- `tools/post-execute` may replace content, block with feedback, or attach context; `tools/result` observes the immutable final outcome. +- Exact signatures and ordering live in the generated [event catalog](../../../docs/cordis-catalog/events.md) and [pipeline](../../../docs/tool-execution-pipeline.md). - MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas. ### Typed tool parameter schemas @@ -77,66 +80,23 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. +A `defineTool` definition validates model arguments before execution and turns violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed and defaults are not applied. Raw-registered tools own their validation. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. -`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model. +Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema. ### Structured-output schema subset -A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it. - -The subset is deliberately narrow and REJECTS LOUD outside it — accepting a keyword the validator doesn't enforce would validate less than the schema promises (accepted-then-ignored). Supported: single-string `type` (`object`/`array`/`string`/`number`/`integer`/`boolean`/`null`; type arrays rejected), `properties`/`required`/`additionalProperties` (boolean; every `required` key must be declared), `items`, scalar-only `enum`/`const`; annotations (`description`/`title`/`default`/`examples`) are ignored but must still be JSON data. `assertSupportedOutputSchema(schema)` throws `OutputSchemaError` (`code: 'UNSUPPORTED_SCHEMA'`, listing every violation) for anything else; `validateStructuredValue(schema, value)` returns path-qualified violations (empty = valid, total — never throws). +`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It supports scalar types, objects, arrays, scalar `enum`/`const`, and annotations. Unsupported or inconsistent keywords fail through `OutputSchemaError`; `validateStructuredValue()` returns path-qualified violations. ### Tool-owned UI presentation -A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`): - -- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of: - - `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`). - - `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card. - - `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`. -- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of: - - `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`. - - `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences). - - `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff). - -Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). With `defineTool`, `args` is the typed `InferArgs` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations. - -```ts -import { defineTool } from '@deepseek-ai/dsh-tools' - -const bash = defineTool({ - name: 'bash', - description: 'Run a shell command.', - parameters: { - command: { type: 'string', required: true, description: 'The command to run.' }, - description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' }, - }, - async execute(args) { - return [{ type: 'text', text: `ran: ${args.command}` }] - }, - // A terminal card: the command is the title, the description renders above it. - presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }), - // A terminal result: the raw output + exit; the bridge derives the fenced fallback. - presentResult: (_args, result) => { - const block = result.content.length === 1 ? result.content[0] : undefined - if (block === undefined || block.type !== 'text') return undefined - return { card: 'terminal', output: block.text } - }, -}) -``` +Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names. The `card` discriminator is `generic`, `terminal`, or `diff`; returning `undefined` selects generic fallback. Result-time presentation may read JSON-serializable `result.meta`, which is persisted for replay. The [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the shapes and rationale. ### Code Mode -Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself. - -- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw. -- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `:code:`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency. -- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. - -The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. When no assembly listener changes the registry's prompt or schema contributions, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope. Each program binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. ### What is NOT here (TODO) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 08edb8d8ae..1f3501e3c3 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,18 +1,6 @@ /** - * Tool registry and execution pipeline. Plugins register tools; the registry - * feeds schemas into the system prompt, and `execute()` dispatches each call - * through `tools/pre-execute` (the extensible allow/deny gate) → monotonic - * registered guards → `tools/execute` (an around-dispatch wrapper for - * timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the - * result, attach context) → the observe-only `tools/result` notification. - * - * The registry also owns HOW its tools are presented to the model — its - * `mode` config: `'native'` (every tool as a wire function definition, - * today's behavior and the default), `'code'` (the registry's canonical wire - * contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or - * `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and - * `ts-types.ts` (the SDK codegen); design in the Code Mode RFC. - * + * Tool registry, model presentation modes, and pre/guard/around/post/result + * execution pipeline. * @module @deepseek-ai/dsh-tools */ @@ -83,79 +71,34 @@ declare module 'cordis' { interface Events { /** - * Waterfall BEFORE a tool runs — the gate where sandbox, permission, and - * hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners - * receive `(exec, next)`: call `next()` to delegate to the default (allow), - * or return a {@link PreToolDecision} without calling `next()` to - * short-circuit. A `deny` skips dispatch and yields an `isError` result; the - * tool body never runs. Input rewrite is deliberately NOT offered here (see - * {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam - * when one is mounted, and degrades to deny otherwise. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a - * listener registered through `agent.ctx` fires only for that agent's - * calls, while a plain plugin listener fires for every call (including - * agent-less ones, which dispatch subject-less). + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise /** - * Around-dispatch waterfall wrapping the registry's core tool dispatch, - * between the `tools/pre-execute` gate and the `tools/post-execute` seam. A - * listener receives `(exec, next)`: call `next()` to delegate to dispatch - * (returning its {@link ToolExecutionResult}, optionally wrapped), or return a - * replacement result without calling `next()` to short-circuit dispatch. The - * base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or - * unknown tool) is already normalized to an `isError` result by the time a - * listener's `await next()` returns, so a wrapper never sees a raw throw from - * the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can - * set or replace the one mutable field, `exec.signal` (e.g. with a per-call - * deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity - * (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the - * pipeline so a wrapper cannot change which tool and scope the pipeline - * accepted. (Cordis `next()` ignores passed arguments and re-invokes - * downstream with the shared payload, so a wrapper changes `exec.signal` in - * place rather than passing a new object to `next()`.) - * Multiple listeners compose by registration order — an outer one wraps the - * inner ones plus dispatch. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by - * `exec.agent` — a listener registered through `agent.ctx` wraps only that - * agent's calls; a plain plugin listener wraps every call (including - * agent-less ones, which dispatch subject-less). + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise /** - * Waterfall AFTER a tool runs — where hook plugins inspect the result and - * accept it (optionally REPLACING the model-facing content, and/or attaching - * `additionalContext` for the next request) or block it with corrective - * `feedback` (Claude Code's `PostToolUse`). Listeners receive - * `(exec, result, next)`: call `next()` to delegate to the default (accept - * unchanged), or return a {@link PostToolDecision} to override. Core tool - * dispatch runs earlier as the base `next()` of the `tools/execute` - * waterfall, all inside `execute`'s outer try/catch (and the tool body keeps - * its own inner try/catch, so a thrown tool still reaches `post-execute` as an - * `isError` result). - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by - * `exec.agent` — a listener registered through `agent.ctx` fires only for - * that agent's calls; a plain plugin listener fires for every call - * (including agent-less ones, which dispatch subject-less). + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. * @param exec - the call that just ran (name, parsed arguments, caller agent). * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise /** - * Synchronous notification of the authoritative FINAL tool outcome, after the - * complete pre/execute/post pipeline, final lossless-JSON validation, and - * outer error normalization. - * Unlike the three waterfalls, this seam cannot transform the result: each - * listener receives the now-frozen execution object and a deep-frozen result - * snapshot; listener failures are contained and logged, and - * {@link ToolRegistry.execute} still returns the outcome. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by - * `exec.agent`, using the same carrier as the pipeline. + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. * @param exec - the execution object that traversed the pipeline. * @param result - a deep-frozen snapshot of the final returned result. * @mode emit @@ -177,15 +120,7 @@ declare module 'cordis' { // TODO(review): revisit these shapes when concurrency metadata becomes useful // (for example, a read-only hint that would permit safe parallel execution). -/** - * What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the - * common case (model-facing content only); the object form additionally attaches - * a tool-private `meta` presentation payload that the registry threads onto the - * `tool/result` session event and hands back to the tool's `presentResult`. - * `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape), - * and MUST be JSON-serializable: it persists on the durable log (the session - * enforces this at `append`), so replay reproduces the card. - */ +/** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown } /** A registered tool: its schema plus the execution function. */ @@ -236,12 +171,7 @@ export interface ToolResult { declare const toolExecutionTokenBrand: unique symbol -/** - * Opaque identity for one trip through the tool pipeline. Nested - * transports carry the enclosing execution's token instead of its live object, - * so observe-only result listeners can correlate calls without gaining a - * mutation path into an outer around-dispatch wrapper. - */ +/** Opaque call identity that permits correlation without exposing mutable execution state. */ export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } /** @@ -308,14 +238,8 @@ export interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing context a `tools/post-execute` listener attached for the - * NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part - * of this call's `content` — `content`/`feedback` shape the tool RESULT, but - * `additionalContext` is a SEPARATE `context/message`. A step can carry - * multiple tool calls, so the loop BUFFERS every call's `additionalContext` - * and appends them only AFTER all `tool/result`s for the step, keeping - * tool-call/result adjacency intact. Carried on the result purely to ferry it - * from `execute()` up to the loop's per-step buffer. + * Model-facing context for the next request, separate from this tool result. + * The loop buffers it until all step results are logged, preserving pairing. */ additionalContext?: HookContext /** @@ -327,38 +251,13 @@ export interface ToolExecutionResult { meta?: unknown } -/** - * The decision a `tools/pre-execute` listener returns for one pending call. - * Maps onto Claude Code's `PreToolUse` `permissionDecision`. - * - * - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` — - * is deliberately NOT offered: `tool/call` and `assistant/message` are logged - * BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash` - * presentation, read the pre-execution arguments, so an execution-only rewrite - * would desync the UI from what RAN. That consistency redesign is its own - * `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.) - * - `deny` skips dispatch; the loop records an `isError` result carrying `reason`. - * - `ask` is the permission-prompt intent: serviced as a one-shot decision by - * the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to - * dispatch; every other outcome denies), degrading to `deny` when none is. - */ +/** Pre-dispatch decision. Input rewriting is excluded because arguments are already logged and presented. */ export type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string } -/** - * The decision a `tools/post-execute` listener returns for one finished call. - * Maps onto Claude Code's `PostToolUse` decision. - * - * - `accept` keeps the call successful; optional `content` REPLACES the - * model-facing result (clean: `tool/result` is logged AFTER `execute()` - * returns, so a replaced result is the single source of truth for both derived - * history and UI). Optional `additionalContext` rides to the next request. - * - `block` turns the call into an `isError` result whose content is the - * corrective `feedback` (the model is told the call was rejected and why), - * optionally also attaching `additionalContext`. - */ +/** Post-dispatch decision: accept or replace content, attach context, or block with corrective feedback. */ export type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } @@ -399,36 +298,13 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { - /** - * The presentation mode. `'native'` (the default) contributes every - * visible end capability as a native wire function definition. Under - * `'code'` this registry contributes exactly ONE wire tool, - * `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a - * TypeScript API the program calls. `'both'` contributes every native - * definition AND `run_code` + the SDK section. Non-native modes require a - * loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing - * or mismatched runtime rejects every prompt assembly with an actionable - * error (misconfiguration fails loud, before any model request). A - * configured `systemPrompt.toolOrder` naming native tools likewise rejects - * every assembly under `'code'` (those names are no longer contributed) — - * a deployment switching modes updates its order config or drops it. - */ + /** Model presentation: native schemas, `run_code` plus SDK, or both. Code modes require a TypeScript runtime. */ mode?: ToolPresentationMode } /** - * A per-scope restriction over the GLOBAL tool surface, registered via - * {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools; - * `deny` removes the listed ones; both present = allow first, then deny. - * Restrictions never touch scoped registrations — a tool registered through - * the same scope is merged after the global filter (which is what keeps e.g. a - * structured-output capture tool alive under an allow-list). The readonly - * filter values compile to private sets at registration, but resolution uses the live global registry: - * a later global name passes a deny-only filter unless explicitly denied and - * fails an allow-list unless explicitly allowed. The - * reserved `run_code` presentation transport is likewise outside capability - * filtering, and naming it explicitly is rejected. Multiple restrictions on - * one scope compose by intersection: every one must admit. + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -469,26 +345,8 @@ interface ToolGuardRegistration { } /** - * Tool registry (`ctx.tools`): tool plugins register definitions; the agent - * loop executes calls through the `tools/pre-execute` → guards → - * `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The - * registry contributes its schemas into the system-prompt assembly — WHICH - * schemas is governed by its `mode` config - * (see {@link Config.mode}); under a non-native mode it also owns the reserved - * `run_code` presentation transport and the `tools:sdk` prompt section. - * - * Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a - * plain plugin context is GLOBAL (visible to every agent); one through a - * scoped context (`agent.ctx`) is filed in that scope's layer — visible to - * that agent alone, disposed with the scope, and SHADOWING a global tool of - * the same name for that agent (most-specific-wins; within one layer a - * duplicate name still throws). {@link restrict} masks the global layer per - * scope. One private visibility resolver feeds the registry's prompt - * contribution, {@link get}, and {@link execute} — and, under a non-native - * mode, the SDK section and `run_code`'s bindings — so those registry-owned - * presentation and dispatch paths agree. An expert `system-prompt/assemble` - * listener may deliberately replace the final wire composition and owns any - * resulting divergence. + * Tool registry and execution pipeline. Scoped registrations shadow globals; + * one visibility resolver feeds presentation, lookup, and dispatch. */ export class ToolRegistry extends Service { static inject = ['systemPrompt'] @@ -526,13 +384,7 @@ export class ToolRegistry extends Service { ctx.systemPrompt.section({ name: 'tools:sdk', order: SDK_SECTION_ORDER, - // A lazy thunk over the live registry, per assembly CONTEXT: - // regenerated at each assembly over the CALLING SCOPE's visible set - // (scoped tools join, restricted globals vanish — the SDK declares - // exactly what that agent's programs can call), in lexicographic - // tool order, so an unchanged tool set renders byte-identical text - // (prefix-cache-friendly) and a mid-session registration surfaces - // exactly like a native-mode tool change. + // Regenerate from the calling scope's visible tools in stable order. text: (context) => { this.requireCodeRuntime() return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME)) @@ -541,24 +393,7 @@ export class ToolRegistry extends Service { } } - /** - * The registry's contribution to the wire tool list, per {@link Config.mode}, - * as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions - * applied — {@link schemas}). Because `PromptAssembly.tools` is what the - * loop's request header snapshots, the mode's collapse is logged and - * reconstructable for free. Under a non-native mode this is also the loud - * misconfiguration gate: no usable code runtime → every assembly rejects - * before any model request. - * - * The `knownNames` universe distinguishes the two ways a tool can be off - * the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays - * pre-restriction and a restricted-away tool in `toolOrder` is a normal - * absence — while the MODE collapse is deployment config, so under - * `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a - * native tool is dead configuration that fails every assembly loud. Under - * `mode: 'both'`, the provider adds the reserved transport to the - * capability-only known-name universe for `toolOrder` validation. - */ + /** Build one scope's wire schemas and pre-restriction names for prompt-order validation. */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) @@ -595,23 +430,10 @@ export class ToolRegistry extends Service { } /** - * Register a tool. The layer is decided by the CALLING context: a plain - * plugin context registers globally; a scoped context (`agent.ctx`) - * registers into that scope's layer — visible to that agent alone, disposed - * with the scope, and shadowing a same-named global tool for that agent. - * Throws if the SAME layer already has the name (cross-layer name twins are - * the shadowing feature, not an error; the global-duplicate message names - * `agent.ctx` as the per-agent alternative), or if a non-native mode reserves - * the `run_code` name for its presentation transport. The visible schema set - * flows into prompt assembly automatically. Definitions are trusted typed - * same-process contributions; JSON materialization happens when the schema or - * result reaches its model/log boundary. Emits `tools/change` on - * register/unregister. - * @param definition - the tool's schema plus its execute (and optional - * presentation) functions. - * @returns the disposer that unregisters the tool. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { const scope = scopeOf(this.ctx) @@ -632,52 +454,26 @@ export class ToolRegistry extends Service { : `tool "${name}" is already registered in this scope`) } layer.set(name, definition) - // Yield the rollback BEFORE emitting `tools/change`: a generator effect - // collects each yielded disposer before the next step runs, so a throwing - // `tools/change` listener removes the tool instead of leaking it (a leak - // would wedge the duplicate-name check until restart). The duplicate - // throw above fires before any mutation — it leaks nothing. + // Install rollback before notifying listeners. yield () => { layer.delete(name) - // An emptied scope layer is dropped so a disposed scope leaves no - // residue keyed by its (dead) key. + // Drop empty scope layers. if (scope !== undefined && layer.size === 0) this.scoped.delete(scope) this.ctx.emit('tools/change') } this.ctx.emit('tools/change') }.bind(this), 'tools.register()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } /** - * Restrict the GLOBAL tool surface for the calling scope. Must be called - * through a scoped context (`agent.ctx`) — restricting "everyone" is not a - * thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op - * that can only be a bug (throw — the materialized-empty-config trap). - * Validates every listed name against the CURRENT global end-capability - * universe and throws on an unknown or scope-local name (fail loud - * beats a typo silently filtering nothing) — register restrictions after the - * global tools they mask exist (the agent-creation `setup` window satisfies - * this). A non-native mode's reserved `run_code` presentation transport is - * not a filterable capability; naming it explicitly throws, while omitting - * it from an allow-list cannot remove it. The readonly arrays are compiled to - * private sets at registration. Resolution still uses the live global registry, so a later - * global name passes a deny-only filter unless named and fails an allow-list - * unless named. Multiple restrictions compose by intersection. Scoped - * registrations are merged after restrictions and therefore remain visible. - * Disposed with the calling fiber (revocable independently); emits - * `tools/change`. + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). - * @returns the disposer that lifts this restriction. The exact - * Cordis effect disposer (single-shot): composite (generator) effects may - * yield it directly — exact identity nests the teardown in order. + * @returns the exact disposer that lifts this restriction. */ restrict(filter: ToolRestriction): () => void { const scope = scopeOf(this.ctx) @@ -715,12 +511,7 @@ export class ToolRegistry extends Service { } this.ctx.emit('tools/change') }.bind(this), 'tools.restrict()') - // The EXACT cordis effect disposer, not a wrapper: a composite (generator) - // effect that owns a teardown ORDER must be able to yield THIS function — - // cordis nests a disposer out of the fiber's concurrent sibling list by - // exact function identity, so a wrapper would silently break the nesting - // (the agents.register() lesson). Cleanup is synchronous because this - // registration installs only synchronous state and notifications. + // Return the exact disposer so composite effects preserve teardown order. // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity return dispose } @@ -842,15 +633,8 @@ export class ToolRegistry extends Service { } /** - * The model-facing schemas of everything `scope` can see — exactly the - * fields (`name`, `description`, `parameters`) this registry contributes to - * system-prompt assembly before its expert transformation waterfall. - * Constructed EXPLICITLY rather than by stripping - * known non-schema members: a `ToolDefinition` also carries `execute` and the - * optional `presentCall`/`presentResult` UI callbacks, and those (especially - * the functions) must never leak into a model request. An allowlist can't - * drift when a new non-schema member is added to the definition; a denylist - * (rest-destructure) would silently leak it. + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. * @param scope - the viewing scope (the agent); omitted = the global view. * @returns one deep-cloned schema per visible tool. */ @@ -869,27 +653,12 @@ export class ToolRegistry extends Service { } /** - * Execute one tool call through the `tools/pre-execute` → guards → - * `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result` - * pipeline. `pre-execute` is the extensible gate - * (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics - * seam), and `post-execute` is the inspect/transform seam; core dispatch sits - * as the base `next()` of the `tools/execute` waterfall. The whole thing is - * wrapped in one outer try/catch so a throwing listener (in any waterfall) - * becomes an `isError` result instead of failing the turn; the tool body ALSO - * keeps its own inner try/catch, so a thrown tool becomes an `isError` result - * that `tools/execute` and `post-execute` listeners can still inspect. If the - * tool is not registered (or not visible to the calling agent — a - * restricted-away global is exactly as absent as a nonexistent one), the - * result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown - * {@link HarnessError} surfaces its `{ name, code }` on the result. Before - * the final observe-only notification, the authoritative outcome is - * materialized as a detached lossless-JSON snapshot; an invalid outcome is - * normalized to an error. + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. - * @returns the materialized final result after every waterfall; listener and - * tool failures resolve as `isError` results rather than rejections. + * @returns the materialized final result. */ async execute(exec: ToolExecutionInput): Promise { const token = createExecutionToken() diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 1499f9eecb..92769e630b 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -43,14 +43,8 @@ export interface FileDiff { } /** - * How a tool wants ONE of its calls shown in a UI (an editor's tool-call card, a - * CLI log line) BEFORE the result is known — the *pending* state. A `card`-tagged - * discriminated union: a tool declares its render INTENT once and a UI bridge - * switches on `card` to map it to the bridge's own wire shape. Provider-neutral — - * the tool owns its presentation, so a UI never special-cases tool names. - * - * Returned by `ToolDefinition.presentCall`. See the render-intent-union - * RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). + * Provider-neutral pending-call presentation. Tools declare one tagged intent; + * UI bridges map it without special-casing tool names. */ export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index d3a2d32e18..bb3a18c191 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,23 +1,4 @@ -/** - * Typed tool-parameter schema DSL. - * - * Plugin authors write per-property specs with `required: true` as a boolean - * (the `SchemaSpec` type). A type-level helper (`InferArgs`) maps a SchemaSpec - * to the TS argument type. At runtime, `schemaSpecToJsonSchema()` converts a - * SchemaSpec to standard JSON Schema (`type: 'object'`, `properties`, - * `required` array) for the wire format sent to the model. - * - * # Why a custom DSL and not schemastery? - * - * Schemastery is a validation/transformation library (StandardSchema v1) used - * for plugin Config. Tool parameters need JSON Schema specifically (the LLM - * wire format), not validation. A lightweight DSL focused on JSON Schema - * generation, with type inference for the tool's `execute` args, gives plugin - * authors the best DX with the smallest surface area. Schemastery would add - * unnecessary indirection and wouldn't cleanly produce JSON Schema. - * - * @module dsh-tools/schema - */ +/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts' @@ -328,39 +309,12 @@ export interface DefineToolOptions { } /** - * Define a tool with a typed parameter schema. - * - * Use this instead of constructing a raw {@link ToolDefinition} for all - * first-party tools. The `parameters` use the boolean-required style - * (`required: true` as a per-property flag), and `execute` receives typed - * args derived from the schema. - * - * ```ts - * const tool = defineTool({ - * name: 'read_file', - * description: 'Read a file from disk.', - * parameters: { - * path: { type: 'string', required: true, description: 'Absolute file path' }, - * offset: { type: 'number' }, - * limit: { type: 'number', description: 'Max lines to read' }, - * }, - * async execute(args) { - * // args: { path: string; offset?: number; limit?: number } - * }, - * }) - * ``` - * - * Raw JSON-Schema tool definitions (from MCP servers) are still accepted - * by `ToolRegistry.register()` directly — `defineTool` is sugar for - * first-party plugin authors. - * + * Define a first-party tool whose execution and presentation arguments are + * inferred from its per-property schema. * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. - * @returns a registry-ready {@link ToolDefinition}: its `execute` validates the - * raw args first (throwing {@link ToolArgsError} on mismatch, which the - * registry turns into an isError result), and its presenters validate softly - * (returning undefined on mismatch, since replay may feed them older-schema - * args). + * @returns a registry-ready definition with strict execution validation and + * soft presenter validation for replay compatibility. */ export function defineTool(options: DefineToolOptions): ToolDefinition { // Object-literal execute methods don't use `this`; the reference is safe. diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index e74766c699..8d50109491 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -703,16 +703,7 @@ describe('ToolRegistry', () => { }) it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => { - // The registry-disposer convention (set by agents.register): the returned - // function IS the cordis effect disposer, so a composite (generator) - // effect that yields it has the unregistration run at that yield's LIFO - // position on owner unload. A wrapper would leave the inner effect - // disposing as a CONCURRENT SIBLING of the composite; the async probe - // below (disposed first, LIFO) yields the event loop exactly like the - // agent factory's stop-and-drain link, and a sibling unregistration fires - // in that window — the probe would observe the tool already gone. Pins - // the convention for the whole register-method family (system-prompt - // registrars, registerProvider, setFactory share the same return). + // The async probe distinguishes nested LIFO teardown from a sibling effect. const ctx = await setup() const order: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index ad912bfc95..e63e256c7e 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -37,7 +37,7 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek ## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a `WeakMap>`. An entry exists **iff** the owner has read, written, OR edited that target (every success emits `fs/observed`), so its presence is the prior-observation record — there is no `hasRead` flag and no `full`/`partial` view. This plugin does **no** filesystem I/O: "have you observed this file?" is a `WeakMap` lookup, and "is the version you read still current?" is decided inside `ctx.fs.editText`/`writeText` in the same atomic lock that performs the mutation — this plugin only supplies `vObserved` as the basis. A windowed read of lines 100-150 records the file's version, and a later edit of line 120 is authorized as long as the file is unchanged. State is held weakly and dropped on disposal (HMR safety); persistence across sessions is deferred. +Observed state is a weak owner-to-target version map updated after every successful read or mutation. The plugin performs no filesystem I/O: it checks whether a version was observed and supplies that version to the provider's atomic mutation guard. State is discarded on plugin disposal and is not persisted across sessions. ## Single-slot, first-wins diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index d370728cb2..c062118531 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -128,10 +128,8 @@ export abstract class FileSystem extends Service { abstract listDir(target: FsTarget, signal?: AbortSignal): Promise /** - * Create or fully replace a UTF-8 text file atomically. `expected` is the - * create-vs-replace decision and stale guard when supplied; OMITTING it is an - * unconditional create-or-overwrite (the bare provider — no version guard, no - * read-first requirement). Atomic either way. + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. * @param target - the resolved target to write. * @param content - the full new file content. * @param expected - the write intent guarding the write; omit for unconditional. diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 61c163c28b..0487962922 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -11,15 +11,9 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' /** - * Shared harness for the fs-tools with-key e2e: a minimal real agent stack (the - * DeepSeek adapter + the real fs provider + the read-before-write/edit policy + - * the model-facing read/write/edit tools). Lives outside the *.e2e.ts pattern so - * importing it never re-registers another file's tests. - * - * `fsCwd` is the local backend's default base; a per-session cwd (set via a - * session header) overrides it, but this harness creates agents without a - * session cwd, so the provider default IS the workspace. `persona` is the - * deployment persona (the system-prompt plugin's per-context config). + * Build the real fs-tool stack for with-key e2e tests. Agents have no session + * cwd, so `fsCwd` is their workspace; `persona` configures the deployment prompt. + * This helper lives outside the e2e glob so imports do not register tests. */ export async function fsHarness(fsCwd: string, persona = ''): Promise { const ctx = new Context() diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index dc385bc033..2fa5c1c482 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -30,7 +30,7 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de ## Reminder delivery -Reminders ride the post-execute decision's `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and folds its reminder onto the downstream decision (both variants — a blocked call still gets the nudge); when a downstream listener attached its own `additionalContext`, the fold concatenates content and carries the guard's `source` (a `HookContext` holds one `MessageSource`; `source.kind` is what framing depends on). +Reminders use source-attributed `additionalContext`, preserving the tool's original result. The loop records them after the step's results as reconstructable `context/message` events. The guard always delegates and folds its reminder onto downstream context, including blocked calls. ## Testing diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8296821503..1edea0ffa6 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). - **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. -- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. Event-specific output applies only when its discriminator matches the firing event, while top-level fields remain event-agnostic. The parser is total and leaves successful non-JSON output to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. - **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence). diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index a281c39047..518ca51c1f 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -83,12 +83,8 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): } /** - * Append a `hook/result` outcome event to `session` (pairs with a prior - * `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's - * parsed decision, else `'stop'` when it asked to halt (`continue: false`), - * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to - * `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode` - * is omitted when the hook never ran. + * Append the durable result paired with `hook/invoked`, normalizing its decision, + * bounded stderr summary, and optional exit code. * @param session - the session whose open turn records the event. * @param record - the outcome to record: the decoded output plus the summary cap and duration. */ diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 414c898341..8d8ee8d442 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -116,14 +116,8 @@ export interface HookOutput { /** The reason/explanation accompanying {@link decision}. */ reason?: string /** - * The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted - * a `hookSpecificOutput` block. The reference schemas key that block by event, - * so a block whose `hookEventName` names a DIFFERENT event than the one firing - * is malformed: {@link parseHookOutput} DISCARDS its event-scoped fields when - * given the firing event's `expectedEventName` (a hook claiming `PreToolUse` - * output on a `Stop` event does not affect the `Stop`). This field is still - * surfaced even on a mismatch — the record shows what the block claimed. Absent - * when the hook emitted no `hookSpecificOutput`. + * Event discriminator claimed by `hookSpecificOutput`. On mismatch, + * {@link parseHookOutput} preserves this value but discards event-scoped fields. */ hookEventName?: string /** Extra context to inject for the next model request (CC `additionalContext`). */ diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 681a6675d9..3a5d50b9db 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -30,11 +30,8 @@ function asObject(value: unknown): Record | undefined { } /** - * Parse a raw Codex `hooks.json` object into runnable {@link MatcherGroup}s. - * Only the five {@link CODEX_EVENTS} are honored; an unknown event is dropped. - * `type !== 'command'` and `async: true` command hooks are skipped (recorded in - * `skipped`). Malformed entries are ignored rather than thrown — a bad config - * must not crash boot. No command substitution (Codex does none). + * Parse supported synchronous command hooks, recording skipped entries and + * ignoring malformed configuration rather than failing boot. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 42694ba5ea..2e6ecd14c7 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -35,7 +35,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### App attribution (`attribution.ts`) -Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). +Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution RFC](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). ### Classes diff --git a/packages/llm/llm/src/never.ts b/packages/llm/llm/src/never.ts index 4666928b46..f25a56ec32 100644 --- a/packages/llm/llm/src/never.ts +++ b/packages/llm/llm/src/never.ts @@ -4,10 +4,8 @@ */ /** - * Marks unreachable code on a closed union. If this is reachable, either a - * variant was added without updating the switch (compile error at the call - * site — the desired outcome) or a value escaped its type (runtime throw - * with diagnostics — the safety net). + * Mark an unreachable closed-union branch and diagnose values that escaped + * static exhaustiveness. * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site. * @param context - optional label (e.g. the switch site) prefixed into the throw message. * @returns never — it always throws, with the offending value JSON-rendered in the message. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 3822fe1115..4b28d0a9fc 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -36,15 +36,8 @@ export interface ToolResultBlock { } /** - * All known content block shapes, keyed by their `type` tag. - * Merge-extensible: plugins add new block types via declaration merging. - * - * The core set is deliberately limited to blocks every shipping path honors. - * Multimodal content (images, audio, …) has no core block type: a feature - * that needs one adds it via declaration merging in the same coordinated - * change that maps it in the adapters, surfaces it in the UI bridges, and - * prices it in compaction — a producer never lands without its consumers - * (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md). + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. */ export interface ContentBlockMap { 'text': TextBlock diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 4a243efaaf..faa7e2fafa 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -1,14 +1,14 @@ # @deepseek-ai/dsh-sandbox-local -Local implementation of the [`@deepseek-ai/dsh-sandbox`](../sandbox/) seam: wraps a caller's argv in a platform confinement runner. Selection is BY PLATFORM, resolved once and cached: each platform names its runner chain, a chain of one is selected directly (probing arbitrates between candidates — a sole candidate leaves nothing to arbitrate), and a chain of several is probed functionally in preference order. Linux: [`bwrap`](https://github.com/containers/bubblewrap) when its probe passes, else the [`landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) Landlock launcher (kernel confinement that needs no userns/mount privileges — see the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the prebuilt-binary decision and profile-parity notes); darwin: `sandbox-exec` speaking a Seatbelt (SBPL) profile, unprobed. A platform with no chain means `confine()` FAILS CLOSED with the seam's structured `SANDBOX_UNAVAILABLE` error (win32 today: a reserved, deliberately empty chain awaiting an AppContainer-family runner); an unprobed runner that turns out unusable fails closed at EXECUTION instead — it refuses to run the command, and every wrap's `runnerFailureSignatures` let the consumer classify that as a sandbox failure rather than a task failure. Never a silent unconfined passthrough on any path. +Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. + +Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences. Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics. The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. -The Landlock launcher comes from the npm package family [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) — an entry package (this package's one runtime dependency) plus per-platform binary packages selected by npm's `os`/`cpu` fields, built and released from [its own repository](https://github.com/deepseek-harness/node-addon-landlock-run). The entry package owns the launcher's CLI contract: `launcherPath()` resolution (a host with no platform package yields a never-existing path whose probe fails exactly like an unenforcing kernel), the functional `probe()`, and `grantArgs()` flag spelling — versioned together with the binary, so probe-report parsing can never drift against it. This provider keeps only the policy side: the mode → grants mapping (`landlockProfileArgs`) and the ladder. The consumer path is rehearsed by `tests/packed-install.e2e.ts`: pack THIS package's closure, install into a throwaway consumer with the launcher family coming from the registry, assert the installed binary executable (a stripped mode bit must not masquerade as a non-enforcing kernel), and confine through it under plain `node`. - -Every rung has its keyless world-proof (`tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, `tests/seatbelt.e2e.ts`), each self-skipping where its runner is absent; CI's `sandbox-e2e` matrix runs all of them against real kernels (bwrap plus one Landlock leg per architecture on Linux, Seatbelt on macOS) and fails on a silent all-skip. +[`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml - id: sandbox diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index df6a73613b..284496e30a 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -28,16 +28,7 @@ export interface Config { * own failure dialect. */ runnerFailureSignatures?: string[] - /** - * Per-probe timeout in milliseconds for the chain's functional probes - * (default: 5000; must be a positive finite number — Node treats a 0 - * `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A - * probe that exceeds it reads as an unusable rung, so a - * host slow enough to trip the default — cold NFS mounts, heavily loaded - * CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no - * config escape. Bounds ONE probe, and the chain walk runs each at most once - * per provider lifetime. - */ + /** Positive timeout for each functional probe; zero would mean unbounded to Node. */ probeTimeoutMs?: number } @@ -112,17 +103,7 @@ export function seatbeltProfileArgs(policy: SandboxPolicy): string[] { return ['-p', forms.join(' ')] } -/** - * Functional `bwrap` probe: can it actually build the read-only profile on - * this host? (`--version` alone would miss a disabled unprivileged user - * namespace.) Synchronous by design — it runs once, lazily, before the first - * confined wrap, and the chain's verdict is cached for the provider's - * lifetime. `timeoutMs` bounds the probe (the `probeTimeoutMs` config). - * The Landlock rung needs no such helper: resolution (`launcherPath`) and - * the functional probe (`probe`) come from `node-addon-landlock-run`, the - * package family that ships the launcher binary itself, so the probe-report - * parsing can never drift against the binary. - */ +/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */ function defaultProbeBwrap(timeoutMs: number): boolean { const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: timeoutMs, diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 990ad2fcc4..4301d0f469 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -23,10 +23,10 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. -- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). +- **Crash recovery — close, don't truncate.** `load` preserves valid events from an interrupted final turn, appends the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), and removes only an incomplete final line. - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path -The plugin generalizes the example `session-jsonl.ts`: it subscribes to `session/created` (capture the header; persist a fork's seed once), `session/event` (copy each already-frozen event into the persistence-owned write-behind buffer), and `session/flush`/dispose (drain that buffer through `append`). A per-session write cursor means a resumed session never re-appends already-stored events. Existing live sessions are seeded on plugin apply (HMR does not replay `session/created`). All backend operations for one session are serialized, and disposal awaits quiescence (every init + final drain) before returning, so no write lands after teardown. +The plugin buffers frozen session events and drains them on flush or disposal. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Operations for one session are serialized; disposal waits for initialization and the final drain so no write lands after teardown. diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 082b60af88..ea209e6a34 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,13 +8,13 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). -- **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. +- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b1fc118a21..d7bec68c9f 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -1,26 +1,6 @@ /** - * The backend-agnostic write-path orchestration shared by every first-party - * {@link SessionPersistence} backend. - * - * Every durable backend needs the same orchestration: the in-memory bookkeeping - * (the per-id state, the write-behind buffers, the per-id serialization chains, - * the per-session init promises), the `session/event` → buffer → `session/flush` - * drain, lazy materialization, crash-tail repair on load, the four - * `session/created` adoption cases (new / HMR-adopt / collision / - * ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives are - * backend-specific (file bytes for `dsh-session-persistence-jsonl`, `node:sqlite` - * rows for `dsh-session-persistence-sqlite`). {@link PersistenceCoordinator} owns - * the orchestration; a backend supplies the storage primitives as a small - * {@link PersistenceBackend} hook object. - * - * The abstract {@link SessionPersistence} service's public API is independent of - * this: a backend IS a `SessionPersistence` (its four public methods delegate to - * a coordinator it composes), so a third-party backend MAY implement the service - * directly without using the coordinator at all. - * - * See the write-coordinator RFC (docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) - * for the design rationale (composition over inheritance, the opaque torn marker). - * + * Shared buffering, serialization, adoption, repair, and disposal orchestration + * over backend-specific persistence primitives. * @module @deepseek-ai/dsh-session-persistence/coordinator */ @@ -30,16 +10,9 @@ import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek- import { seedCoversPrefix } from './index.ts' /** - * A stored session's durable prefix as read back from a backend: its - * {@link SessionHeader}, the preserved (seq-contiguous, parseable) event prefix, - * and an OPAQUE `tornMarker` that is present iff a never-committed torn tail must - * be truncated before further writes. - * - * The coordinator NEVER inspects `tornMarker`'s value — it only tests - * `!== undefined` (is there a tail to repair?) and passes the value back to - * {@link PersistenceBackend.commitRepair}. Each backend chooses its own marker - * type: the JSONL backend uses the byte offset to truncate to, the SQLite - * backend uses the seq to delete from (both happen to be `number`). + * A stored session's header, valid contiguous event prefix, and optional opaque + * torn-tail marker. The coordinator only checks marker presence and returns its + * value to {@link PersistenceBackend.commitRepair}; each backend owns the type. */ export interface StoredPrefix { meta: SessionHeader @@ -111,18 +84,7 @@ interface SessionState { meta: SessionHeader /** The next seq the backend expects to append (the stored log length). */ cursor: number - /** - * Whether the backend has physically written this session (a JSONL file / - * SQLite row exists). `create()` registers state LAZILY — cursor 0, - * materialized false, nothing on disk — so an empty session leaves no - * artifact and the FIRST `appendBatch` writes the header + its events in ONE - * transaction (the "a row exists ⇔ it has events" invariant `list` - * relies on; a separate up-front materialize could crash leaving a row with - * zero events). The flag is the only signal that distinguishes a session - * registered-but-never-written from one durably present, which the reclaim - * path needs (an abandoned id with no artifact AND no buffered events is free - * to reuse; a materialized one is a real collision). - */ + /** Whether lazy creation has produced a durable artifact. */ materialized: boolean /** * The live Session this state was bound to via `onCreated`, if any. State @@ -166,14 +128,9 @@ export class PersistenceCoordinator { */ private chains = new Map>() /** - * Per-session init promise (onCreated). Keyed by the LIVE Session OBJECT, not - * its id: a disposed fiber's session can be replaced by a different live - * Session reusing the same id (HMR, an ACP reconnect), and an id-keyed cache - * would hand the new object the old object's init promise. - * - * Public (readonly) so a backend can expose it for white-box tests that await - * a specific session's init (there is no public API to await one init); the - * coordinator itself only ever mutates it internally. + * Init promises keyed by live session object, preventing an id-reusing + * replacement from inheriting stale initialization. Readonly access supports + * backend white-box tests. */ readonly inits = new Map>() @@ -184,16 +141,11 @@ export class PersistenceCoordinator { // --- public surface (the backend's service methods delegate here) --- /** - * Register a new session's metadata (lazy: no physical write until the first - * {@link append}). Rejects if the id is already tracked or already persisted. - * @param meta - the header (id, version, cwd, lineage) to record; materialized - * as a detached lossless-JSON snapshot at call time. + * Register detached session metadata for lazy creation on the first append. + * @param meta - header to snapshot; duplicate tracked or persisted ids reject. */ create(meta: SessionHeader): Promise { - // Snapshot the metadata at call time: the op runs later (behind the - // per-session chain) and the snapshot is stored as the lazy state, so keeping - // the caller's object by reference would let a later mutation of `id`/`cwd` - // register under one key but materialize under a different path/header. + // Snapshot before queueing so caller mutation cannot diverge the key and header. const snapshot = snapshotJsonValue(meta) if (snapshot === undefined) { return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable')) @@ -274,32 +226,20 @@ export class PersistenceCoordinator { const { meta, events, tornMarker } = stored this.assertVersion(meta) - // Crash-recovery: if the log ended mid-turn (real, preserved events but no - // closing turn/end), close it durably DURING load so disk, the returned log, - // and the cursor all agree. The interrupted turn's real events are preserved, - // never truncated (a turn can be huge — the session-persistence RFC); only a - // never-fully-written torn tail fragment is discarded. + // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(events) const balanced = [...events, ...closers] - // Make the repair durable (truncate the torn tail + append the synthetic - // closers) BEFORE recording state — commitRepair takes `meta` directly, so - // there is no state-path ordering dependency (uniform across backends). + // Repair storage before publishing coordinator state. if (tornMarker !== undefined || closers.length > 0) { await this.backend.commitRepair(meta, tornMarker, closers) } - // The state keeps its OWN copy of the meta; the returned value is separate so - // a consumer mutating loaded.meta cannot corrupt the backend's metadata. + // Keep coordinator metadata detached from the returned record. this.states.set(id, { meta: { ...meta }, cursor: balanced.length, materialized: true }) return { meta, events: balanced } } - // NOTE: there is deliberately no coordinator `list()`. Listing needs none of - // the coordinator's orchestration (no per-id serialization, no cursor, no - // in-memory state) — it is a pure read of stored metadata. A backend's public - // `list()` IS the {@link PersistenceBackend.list} hook (one method); routing it - // through the coordinator would only forward to that same hook, so the - // coordinator stays out of the listing path entirely. + // Listing is a direct backend read and needs no coordinator state. // --- per-id serialization + adoption helpers --- diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index b03691d17b..53a0feebcf 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -1,23 +1,6 @@ /** - * The durable session-persistence seam (`ctx.sessionPersistence`): an abstract - * service defining WHAT a persistence backend does — durably store, reload, - * and list sessions — without saying HOW. Implementations subclass - * {@link SessionPersistence} and register themselves as the - * `sessionPersistence` service; `@deepseek-ai/dsh-session-persistence-jsonl` - * (an append-only JSONL log per session) is the first and - * `@deepseek-ai/dsh-session-persistence-sqlite` (`node:sqlite`, one row per - * event) is a second that validates the seam is backend-agnostic by passing - * the same `runPersistenceContract` suite. Further backends swap in an object - * store or a remote service without touching the consumers (the write-path - * plugin, the agent-loop resume seam). - * - * The persisted unit IS the existing {@link SessionEvent} — there is no - * parallel "persisted message" type the log must be converted to and from - * (faithful to the event-sourced model: the log is the single source of - * truth). Metadata that is NOT replayable conversation state (format version, - * cwd, lineage, seed boundary) travels separately as {@link SessionHeader}, - * which is owned by `dsh-session` and re-exported here. - * + * Durable session-persistence seam. Backends store {@link SessionEvent}s plus + * separate {@link SessionHeader} metadata. * @module @deepseek-ai/dsh-session-persistence */ @@ -39,12 +22,8 @@ declare module 'cordis' { } /** - * Whether a live session's seed reproduces a persisted prefix exactly. Backends - * use this collision check to distinguish a legitimate resume/HMR rebind from a - * different live session reusing an existing session id. - * - * The comparison includes the full event payload, not just seq/type/time, so a - * mutated seed cannot be grafted onto a durable log with the same envelope. + * Check whether a live seed exactly reproduces a durable prefix, including full + * payloads. This distinguishes resume/HMR rebinding from an id collision. * @param seed - the live session's creation-time event snapshot. * @param prefix - the persisted prefix the seed must reproduce. * @returns `true` when the prefix fits within the seed and every event matches by JSON text. @@ -72,32 +51,10 @@ export function assertSerializable(events: readonly SessionEvent[]): void { } /** - * Abstract durable session-persistence service. Subclass, implement the - * abstract methods, and load the subclass as a plugin — it registers as - * `ctx.sessionPersistence` (one implementation per context; loading a second - * throws, cordis' standard duplicate-service behavior). - * - * Contracts every implementation MUST honor (a DB backend asserts them inside - * a transaction; a file backend appends at EOF): - * - * - **Append-only; a crashed turn is closed, not truncated.** Committed events - * — those at or below a flushed `turn/end` — are never rewritten. A crash can - * leave an unclosed final turn whose events are real (and possibly large); - * {@link load} preserves them and closes the orphaned turn with synthetic - * boundary events (see {@link load}). Only a never-fully-written torn tail - * fragment is discarded. - * - **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. - * {@link load} rejects a parse error or a `seq` gap in the COMMITTED region - * (unloadable); {@link append}'s first event `seq` MUST equal the backend's - * stored next-seq (after `load` has balanced any interrupted turn). - * - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so - * {@link append} materializes each complete batch through the shared - * lossless-JSON boundary before buffering it. The public `session.events` - * view is immutable, but persistence still snapshots direct/replay callers at - * this independent trust boundary. - * - **Durability.** {@link append} returns only once the batch is durable - * (the file backend fsyncs; a DB commits). {@link create} MAY defer the - * physical write until the first {@link append} (lazy materialization). + * Durable append-only session storage. Implementations preserve contiguous, + * losslessly JSON-serializable events; {@link append} resolves only after + * durability, and {@link load} balances a complete interrupted tail without + * rewriting committed events. */ export abstract class SessionPersistence extends Service { constructor(ctx: Context) { @@ -125,29 +82,12 @@ export abstract class SessionPersistence extends Service { abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** - * Reload a session: its {@link SessionHeader} plus the event log up to the last - * durable checkpoint. Returns `meta` AND `events` so the live session is - * reconstructed with its `cwd`/lineage, not just its log. - * - * The loop only flushes at `turn/end`, so a crash can leave a durable log - * whose final turn never closed: real, fully-written events sit after the last - * `turn/end`. Those events are PRESERVED — a single turn can be huge in a - * long-horizon task, so truncating it would destroy real work — and `load` - * CLOSES the orphaned turn by durably appending the minimal synthetic boundary - * events: an error `tool/result` for every `tool-call` the crash left - * unanswered (so the rehydrated history is a valid provider transcript — a - * dangling assistant tool-call is otherwise rejected), then a `step/end` if a - * step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }` - * reason. The returned `events` therefore end on a balanced `turn/end` and are - * immediately usable as a session seed. Only a never-fully-written TORN tail - * fragment (a half-written final record) is discarded. Returned events are - * contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the - * COMMITTED region (at or before the last real `turn/end`) makes the session - * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for - * the crash-recovery contract. + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and closed with missing tool errors and boundary events; + * only a torn final record is discarded. Unknown versions and corruption in + * the committed prefix reject. * @param id - the persisted session to reload. - * @returns the header plus the event log, ending on a balanced `turn/end` — - * immediately usable as a session seed. + * @returns the header and a log ending on a balanced `turn/end`. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 3674db4525..51a4cc9ecb 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -16,26 +16,10 @@ import { meta, oneTurnLog, appendLog } from './contract.ts' * the suite mounts/disposes backend instances on it and cleans it up at the end. */ export interface CoordinatorFixture { - /** - * Mount the REAL backend plugin (via `ctx.plugin`, the Loader path) on `ctx`, - * over THIS fixture's shared storage scope. Returns the plugin fiber so the - * suite can dispose a single instance (HMR/reload) while the storage — and any - * still-live session in another fiber — survives. The caller has already - * mounted `SessionStore` on `ctx`. - */ + /** Mount a backend over shared fixture storage and return its disposable fiber. */ mount: (ctx: Context) => Promise - /** - * Inject a NEVER-COMMITTED torn tail into the backend's storage for `id` at - * the given `cwd` (the cwd the session was created with): a half-written - * record past the committed region (JSONL: a partial line with no newline; - * SQLite: a row with invalid `data` JSON past the committed seq). This drives - * the coordinator's `loadCore` `tornMarker !== undefined` → `commitRepair` - * branch against real storage. - * - * OMITTED by a backend that structurally has no torn tails (memory): the - * torn-tail scenario then self-skips (asserted explicitly in the suite). - */ + /** Inject an uncommitted torn tail; absent for backends that cannot produce one. */ corruptTail?: (id: SessionId, cwd: string | undefined) => Promise /** Tear down the storage scope (remove the temp dir / file). */ diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 1f0c8d552b..16f31da4dc 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -21,11 +21,11 @@ This package owns the `ctx.skills` interface. It does not know whether skills co ## Provider Contract -A provider registers synchronously from its `apply()` and returns `readonly SkillCandidate[]` from `list(options)` when discovery is requested. The provider, lookup options, candidates, and loaded definitions are readonly same-process contracts: the registry borrows them rather than cloning, freezing, or rebinding callbacks. Remote setup, authentication, and discovery belong in the awaited `list()` call rather than plugin registration. Providers should stop promptly when `options.signal` aborts; the registry also stops awaiting uncooperative discovery and loading work so agent cancellation cannot hang prefix composition or skill loading. +A provider registers synchronously and performs remote setup, authentication, and discovery in its awaited `list(options)` call. Provider objects, lookup options, candidates, and definitions are borrowed readonly rather than cloned or rebound. Providers should honor `options.signal`; the registry also stops awaiting uncooperative discovery or loading after cancellation. -The registry validates parsed provider candidates before caching them and validates loaded definitions before returning them. The winning provider receives the exact candidate and opaque `locator` identity it returned from `list()`; a local provider can therefore use a file-path handle while a remote provider can use a URL, id, or version token. Callers and providers must honor the readonly contract after handing values to the registry. +The registry validates candidates before caching and definitions before returning them. The winning provider receives the same candidate and opaque `locator` it returned from `list()`, allowing backend-specific file, URL, id, or version handles. Callers and providers must preserve the readonly contract. -Parsed candidate and loaded-definition fields are validated at the provider boundary: names/descriptions/content use their declared string types, ranks are finite numbers, and `disableModelInvocation` is boolean when present. Candidate contract violations fail fast because the provider or its parser is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Only completed catalogs are cached, and a provider/runtime revision change during discovery discards the stale result and retries. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final summary list is sorted by skill `name` for deterministic consumers. +Contract violations fail fast. A rejected `list()` is treated as a transient source failure: it is logged, skipped, and not cached. Only completed catalogs are cached; a provider or runtime revision change discards an in-flight result and retries. Duplicate names resolve by rank, provider registration order, then provider-local order. Summaries are sorted by skill name. ## Runtime Skills diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 5ef3f78465..1d2513203b 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -175,14 +175,9 @@ export class SkillService extends Service { } /** - * Register a skill provider synchronously during the provider plugin's - * `apply()`. Throws if another provider already owns the same provider name, - * including the reserved runtime provider name. Providers that need remote - * initialization do that work inside `list()` after registration. Providers - * are readonly same-process registrations: the registry borrows the provider - * object and invokes its methods directly. Effect-scoped and HMR-safe: - * disposing the caller's fiber unregisters the provider and invalidates - * cached catalogs. + * Register a borrowed same-process provider. Duplicate and reserved names + * throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. * @param provider - the provider to register by `provider.name`. * @returns the exact Cordis effect disposer that unregisters this provider; * composite effects may yield it directly to preserve teardown ordering. @@ -215,16 +210,11 @@ export class SkillService extends Service { } /** - * Register a runtime skill contribution. Runtime registrations are treated as - * embedded provider entries with project-over-user priority. Same-name runtime - * registrations are first-wins: a duplicate logs a warning and gets a no-op - * disposer so it cannot remove the active contribution. Runtime definitions - * are readonly same-process registrations; the registry borrows their nested - * resource metadata. + * Register a borrowed readonly runtime skill. Project entries outrank runtime + * entries, which outrank user entries. A duplicate is ignored with a no-op + * disposer so it cannot remove the first registration. * @param skill - the complete skill definition to expose for discovery. - * @returns the exact Cordis effect disposer that removes this runtime - * contribution and invalidates caches; composite effects may yield it - * directly to preserve teardown ordering. + * @returns the exact Cordis disposer, which also invalidates caches. */ register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) @@ -266,12 +256,8 @@ export class SkillService extends Service { } /** - * Load one full skill definition by name. The provider receives the winning - * candidate it returned during discovery, including its opaque locator, and - * the registry returns the provider's definition after validating it. - * Cancellation is rechecked after catalog - * selection (including a cache hit), and provider loading is raced against the - * same signal so an uncooperative provider cannot hang the caller. + * Load and validate the winning provider candidate. Cancellation is checked + * after selection and raced against provider loading. * @param name - kebab-case skill name. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5222906a2d..978d1cb596 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -1,24 +1,6 @@ /** - * The out-of-process ACP subagent run driver. Spawns a child agent as a - * subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the - * CLIENT, drives one session to completion, and shapes the result into a - * {@link SubagentResult}. The mirror image of the server-side bridge in - * `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP - * *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we - * IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`). - * - * One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly - * one ACP session, and `dispose` kills the subprocess and awaits its exit. - * Persistent-process pooling is a future optimization (see the RFC). - * - * TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a - * distinct replay shape — each child is its own PROCESS with its own - * single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own - * sessions-root + fixture), unlike the in-process per-session keying in - * `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a - * scripted mock ACP server subprocess, and the with-key e2e drives the real - * `acp-agent` example. See the ACP-subagent-backend RFC. - * + * Fresh-process ACP subagent client. Drives one child session and owns process + * cancellation and quiescent disposal. * @module @deepseek-ai/dsh-subagent-acp/run */ @@ -42,16 +24,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' -/** - * How the client answers a child's `session/request_permission`. The first cut - * does not surface permission prompts to a human, so every request is - * auto-answered by this fixed policy: - * - * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child - * that asks before a side effect does not get to take it. - * - `allow` — approve every prompt by selecting its first `allow_*` option (or, - * if none is offered, `cancelled`). Use when the child is trusted to act. - */ +/** Fixed response to child permission requests: reject, or first allow option. */ export type PermissionPolicy = 'allow' | 'reject' /** Resolved spawn spec for an ACP child process (no defaults — see Config). */ @@ -95,18 +68,7 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** - * Default grace for the child's EOF-driven quiesce on dispose (the - * `disposeEofGraceMs` config) — the window for it to flush persistence and tear - * down its OWN nested subprocesses (which may run their own `SIGTERM`→`SIGKILL` - * escalation) before the parent escalates to a signal. Deliberately LARGER than - * {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative child whose teardown is itself - * waiting on a signal-trapping grandchild (e.g. a bash subprocess in its own ~3s - * SIGTERM→SIGKILL grace) plus a final flush needs MORE than a single - * signal-grace of headroom, or the parent's SIGTERM cuts it off exactly as it - * reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, so this is - * a standalone generous default, NOT derived from any child's internals. - */ +/** Default EOF grace for child flush and nested-process teardown before signaling. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ @@ -174,16 +136,9 @@ function toError(value: unknown): Error { } /** - * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. - * - * Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, - * and drives one session: `initialize` → `newSession` → `prompt`. The accumulated - * `agent_message_chunk` text is the result output; the prompt's terminal - * `StopReason` maps to the stop reason. `result` never REJECTS on a child-level - * failure after publication resolves with `stopReason: 'error'`. A spawn, - * initialize, new-session, or pre-publication cancellation failure instead - * rejects only after the process has been reaped. `dispose()` requests ACP - * cancellation, then kills and reaps the subprocess. + * Start and publish one ACP child after initialization and session creation. + * Child failures resolve through the run result; startup failures reject after + * process reap. Disposal cancels, kills, and reaps the child. * @param request - the start request; its signal is the cancellation channel. * @param spec - the resolved spawn spec: command/args/cwd, env, permission * policy, dispose graces, and the optional error sink. @@ -194,24 +149,16 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') - // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP - // response channel, stderr = INHERIT so the child's diagnostics surface on the - // parent's stderr (no separate capture to drain — we don't fold child stderr - // into the result; the seam reports only output + stop reason). + // Keep diagnostics on parent stderr; only ACP output contributes to the result. const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: buildChildEnv(spec.env), stdio: ['pipe', 'pipe', 'inherit'], }) - // Same-tick capture (the library's contract): a spawn-level failure (e.g. - // ENOENT for a bad command) is an `error` EVENT that would crash the parent - // unheard; the result path races this promise, so a bad command settles - // `error` like any child failure. + // Capture the child-process error event immediately. const spawnFailed = spawnFailure(child) - // One memoized quiescence transaction is shared by startup rollback and the - // published run's disposer. Once start fulfills, only the holder can invoke - // it; before fulfillment the provider invokes it on every failure path. + // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, { disposeEofGraceMs: spec.disposeEofGraceMs, @@ -220,12 +167,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] - // `cancelled` records that the required signal or disposal requested cancel, so a - // run torn down before the prompt resolves settles `aborted` rather than the - // generic error mapping. Held on a mutable object so the async closures that - // set it (the abort listener) and the IIFE that reads it don't fight TS's - // control-flow narrowing of a bare `let` (which would type the catch-time read - // as always-`false`). + // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } const makeClient = (_agent: AcpAgent): Client => ({ @@ -261,28 +203,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe ) let sessionId: string | undefined - // Resolves when a cancel is requested, so `result` can settle `aborted` even - // if the child never cooperates with `session/cancel` (it ignores the notify, - // or the prompt wedges). The result path races this against the ACP drive: the - // FIRST to settle wins, so signal/dispose cancellation always honors the contract (`result` - // settles `aborted`) without waiting on a non-cooperative child. `dispose` - // still kills the process and reaps it; this only unblocks `result`. The - // executor runs synchronously, so `signalCancelSettled` is assigned before the - // Promise constructor returns (the `!` asserts the definite assignment). + // Cancellation settles the result without waiting for a cooperative child. let signalCancelSettled!: () => void const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) const requestCancel = (): void => { if (flags.cancelled) return flags.cancelled = true signalCancelSettled() - // Best-effort: tell the child to cancel the in-flight turn. Swallows a - // rejection — the session may not exist yet, or the pipe may be gone; the - // dispose path kills the process regardless. If the session has NOT been - // created yet (cancel raced ahead of `newSession`), the `cancelled` flag - // alone carries it: the result path re-checks the flag after each await and - // settles `aborted` without running the prompt. The `.catch` swallow is - // defensive for a narrow transport race (child gone mid-send) — v8-ignored - // because dispose kills the process regardless, so it can't be hit in tests. + // Best-effort ACP cancel; process teardown remains authoritative. /* v8 ignore next */ if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) } @@ -324,12 +252,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const result: Promise = (async (): Promise => { try { - // Race two post-publication outcomes, first to settle wins: - // - prompt: the normal remote turn; - // - cancelSettled: a cancel was requested — settle `aborted` immediately - // rather than waiting on a child that may ignore `session/cancel` or - // wedge the prompt (`result` settles `aborted`). After `newSession` - // succeeds, transport/process failure rejects the in-flight prompt RPC. + // Race the remote turn against local cancellation. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) @@ -340,23 +263,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), ]) } catch (error: unknown) { - // A deterministic cancellation resolves `cancelSettled` before its - // best-effort ACP cancel can reject the prompt. This fallback is only for - // a process/pipe rejection already queued when the abort event fires; its - // first-outcome ordering cannot be forced without a timing-dependent test. + // Cover a process rejection already queued when cancellation arrives. /* v8 ignore next */ if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } - // The seam contract: result resolves (never rejects) on a child-level - // failure. Startup failures were already rejected before publication; - // every rejection here is a prompt transport/RPC failure. - // Flatten to `error` and surface the original via onError so a real fault - // is preserved rather than silently lost. + // Flatten post-publication transport failures while preserving diagnostics. try { spec.onError?.(toError(error), 'error') } catch { - // Swallows only the caller-supplied sink's OWN throw: an unguarded - // sink exception would reject `result` and break the contract above. - // The child-level failure being reported still settles as `error`. + // The diagnostic sink cannot reject the run result. } return { output: collectOutput(), stopReason: 'error' } } finally { @@ -372,15 +286,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (disposal !== undefined) return disposal request.signal.removeEventListener('abort', onAbort) requestCancel() - // Quiescent teardown via the shared ladder (stdin EOF → SIGTERM → - // SIGKILL, awaiting the actual exit). For THIS child the EOF tier is the - // one that matters: our acp-agent has NO SIGTERM handler in a normal - // session — it tears down via the server bridge's connection-close path - // (conn.closed → per-agent dispose → final session/flush), driven by the - // stdin EOF, NOT by a signal — and a prompt response can resolve from a - // turn/end BEFORE that post-turn flush lands, so the child still has - // durable work owed when dispose runs (hence the wide EOF grace; see - // DEFAULT_DISPOSE_EOF_GRACE_MS). + // The shared EOF → TERM → KILL ladder awaits exit. ACP normally quiesces + // from stdin EOF, including the final flush, so this backend uses a wider + // EOF grace before signals escalate. disposal = disposeProcess() return disposal }, diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 16ecad7bd4..835c3aa0c0 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -1,42 +1,6 @@ /** - * Structured-output support for the in-process subagent backends: the - * mechanism behind `SubagentStartRequest.outputSchema` for children that run - * as agents on the same context. - * - * Everything is a SCOPED registration on the child agent's context - * (`child.ctx`, the dsh-scope seam): the `structured_output` capture tool - * carries the run's REAL schema as its registered parameters (each child sees - * exactly its own schema — two concurrent structured runs never interact), the - * demand instruction is an ordinary order-190 scoped section, and the - * enforcement listeners fire only for this child (scope-filtered dispatch). - * Registration lifetime rides the child's fiber, so a backend hot-reload - * mid-run cannot unregister the capture tool out from under a live child, and - * a disposed child leaves no residue — no placeholder schema, - * strip-for-everyone-else pass, or refcounted global runtime. - * - * The child scope's registrations enforce the contract: - * - * - The scoped capture tool and instruction are ordinary assembly inputs. The - * loop logs the assembled request header, so the demand is reconstructable - * log state rather than a wire-only mutation. As with every other assembly - * contribution, an expert `system-prompt/assemble` listener that deliberately - * removes or replaces either input owns the resulting composition. - * - `agent/turn-stop` (serial, scoped): stop the child's turn once its output - * is captured. This terminal checkpoint runs after the ordinary continuation - * waterfall and steering folding, so listener order cannot resurrect a - * completed structured run or carry terminal steering into another turn. - * - `tools.guard()` is the monotonic terminal gate after the extensible - * pre-execute waterfall: once capture commits, no later listener can turn - * the denial back into a dispatched side effect. - * - `tools/result` is the capture COMMIT point. The tool body only STAGES the - * validated value in a WeakMap keyed by the execution object; the awaited, - * non-transforming notification promotes it only when the authoritative - * result after the whole pre/execute/post pipeline succeeds. For a Code Mode - * sub-dispatch, promotion waits again for the enclosing `run_code` result, so - * a runtime failure or outer post-policy block cannot report structured - * success. Execution identity makes call-id reuse and orphaned stages - * irrelevant. - * + * Child-scoped structured-output tool, prompt instruction, terminal guard, and + * authoritative result capture for in-process subagents. * @module @deepseek-ai/dsh-subagent-inprocess/structured */ @@ -70,11 +34,8 @@ export interface StructuredAttachment { } /** - * Attach the structured-output runtime to a child for `schema`: register the - * scoped capture tool (real schema), the scoped instruction section, and the - * scoped enforcement registrations (see the module doc). Call from the - * agent-creation `setup` window with the child's scope context — every - * registration rides the child's fiber and unwinds with the child. + * Attach the scoped capture tool, instruction, and enforcement to a child during + * its creation window. Child disposal removes every registration. * @param childCtx - the child agent's scope context (`setup`'s argument). * @param schema - the trusted, already-asserted schema subset to enforce (see * `assertSupportedOutputSchema` in dsh-tools). diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3e23123622..5a751ac99d 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -35,14 +35,7 @@ const SCHEMA: StructuredOutputSchema = { required: ['answer'], } -/** - * Real loop + scripted mock model + an INLINE fresh-conversation provider over the - * shared driver. The concrete backend plugins are deliberately NOT loaded — - * they would devDep-cycle this package (spawn/fork already depend on the - * driver), and the runtime under test is the driver's; plugin-level structured - * coverage lives in the spawn/fork specs. The mock model script drives the - * child's structured_output calls. - */ +/** Real loop and inline provider without a backend package dependency cycle. */ async function setup(script: Script, options: SetupOptions = {}) { const ctx = new Context() const adapter = new MockAdapter(script) @@ -216,11 +209,7 @@ describe('in-process structured output', () => { ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) let wrapperInstalled = false - // Register before the ready-only start. The child session-start boundary is - // after unpublished setup attached structured output but before the loop - // can run. The wrapper awaits the - // explicit downstream stop above, then overwrites that result with continue. - // The later terminal checkpoint still wins. + // Install a wrapper before the child loop can run. ctx.on('agent/session-start', (child) => { if (child === parent) return wrapperInstalled = true @@ -244,10 +233,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), textResponse('MUST NOT BE CONSUMED'), ]) - // The downstream ordinary policy says stop. A wrapper registered after - // start() delegates to that stop, then queues steering; ordinary folding - // would turn the stop back into continue. The terminal checkpoint runs - // afterwards and discards that steering. + // The terminal checkpoint must discard steering queued by a wrapper. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 9e4e489882..08cda6e89f 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -152,11 +152,7 @@ describe('dsh-subagent-spawn', () => { }) it('rejects without publishing when the request signal is already aborted', async () => { - // Regression: a signal aborted BEFORE the run starts never fires an `abort` - // event, so the listener can't catch it. The driver must check the - // already-aborted case up front and settle `aborted` without running the - // child — otherwise an already-cancelled request runs to `completed`. The - // empty script proves the child's model is never called. + // An already-aborted signal will not emit another abort event. const controller = new AbortController() controller.abort() const { ctx, parent } = await setup([]) @@ -165,10 +161,7 @@ describe('dsh-subagent-spawn', () => { }) it('same-tick cancellation rejects start and prevents child publication', async () => { - // Regression: cancellation before publication used to set a flag but let the - // async factory publish a child anyway, so `started` fulfilled and lifecycle - // observers saw an agent for an attempt the caller had already cancelled. - // The empty script also proves no model turn can run. + // Same-tick cancellation must win before publication. const { ctx, parent } = await setup([]) const beforeAgents = ctx.agents.list().length const beforeSessions = ctx.sessions.list().length diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index cc55fcb9fc..f83df7a4fe 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -54,14 +54,8 @@ export interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Optional structured-output schema — an object-rooted JSON Schema within the - * enforced subset (see `assertSupportedOutputSchema` in dsh-tools; a schema - * outside the subset is rejected loud at start). When set AND the provider's - * {@link SubagentCapabilities.outputSchema} is `true`, the child is driven to - * report a value matching this schema, surfaced as - * {@link SubagentResult.structured}. The schema must be plain host-realm JSON - * data — a caller holding foreign-realm data materializes it first. - * Requesting it against a provider that lacks the capability is rejected at start. + * Supported object-rooted JSON Schema for {@link SubagentResult.structured}. + * Requires the provider capability and plain host-realm JSON data. */ readonly outputSchema?: StructuredOutputSchema /** @@ -130,14 +124,8 @@ export interface SubagentResult { } /** - * A live subagent run: a handle the consumer holds while a child executes. - * Returned by {@link SubagentProvider.start} (via the service) only after the - * child is ready. The consumer awaits {@link result} and MUST {@link dispose} - * on every path to cancel any remaining work and reach child quiescence. - * - * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports - * the runtime capability defines the method; one that doesn't omits it. The - * presence of the method IS the capability — narrow before calling. + * Ready child handle. Consumers await {@link result} and always {@link dispose} + * for quiescence. Optional methods indicate their runtime capabilities. */ export interface SubagentRun { /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ @@ -182,15 +170,9 @@ export interface SubagentProvider { /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities /** - * The provider's conversation-history descriptor: `true` when a child SEES the parent - * conversation (fork — the child is seeded with the parent's completed-turn - * prefix), `false` when it starts fresh (spawn, ACP). A DESCRIPTIVE fact, - * not a start-time capability: the service validates nothing against it — - * the model-facing consumer (`dsh-tool-subagent`) derives truthful tool - * wording from it, so a tool bound to a fork provider stops telling the - * model the child "does not see this conversation". This descriptor concerns - * conversation history only; it says nothing about tool registrations, - * injected services, or authority inheritance. + * Whether a child receives the parent's completed conversation history. This + * descriptive fact drives tool wording; it says nothing about services, tools, + * or authority. */ readonly inheritsParentContext: boolean /** diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d07e6726dd..34f89d072f 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,34 +1,6 @@ /** - * The model-facing `subagent` tool: delegate a task to a child agent and return - * its final output. Pure schema + lifecycle shaping — every transport concern - * lives behind the `ctx.subagents` provider registry - * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend - * swaps in without touching what the model sees. - * - * Provider selection is config, not model-facing: this plugin is bound to - * EXACTLY ONE provider name (`Config.provider`). To expose more than one - * transport, load the plugin more than once, each bound to a different provider - * — there is no provider/type parameter in the model-facing schema. The model - * sees only `{ description, prompt }`. - * - * The tool DESCRIPTION is derived from the bound provider's conversation-history - * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, - * ACP) gets the standalone-prompt wording, while a seeded-conversation provider - * (fork) tells the model the child already sees the conversation's completed - * turns. This descriptor says nothing about Cordis scope, services, tools, or - * authority. The tool MIRRORS the - * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers - * when the provider is (or becomes) available and unregisters when the - * provider goes away — so no load-order requirement exists and an HMR reload - * of the backend re-derives the wording from the fresh provider. - * - * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits - * `run.result` inside a `try/finally` that always disposes the run, so the - * owned child agent/session is torn down on every path (success, error, abort) - * and never leaks as a live idle child. A non-`completed` stop reason maps to an - * `isError` tool result (by throwing) rather than returning partial output as - * success. - * + * Provider-bound model tool that delegates to one child agent, awaits its + * result, and always disposes the run. Provider lifecycle controls registration. * @module @deepseek-ai/dsh-tool-subagent */ @@ -105,16 +77,7 @@ export const Config: z = z.object({ model: z.string(), }).default(undefined as unknown as { model: string }), persona: z.string(), - // A schemastery object materializes {} (with [] for nested arrays) when the - // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. - // deny-everything, silently. Force the omitted key to stay absent (the same - // shape discipline as SystemPrompt's toolOrder); the cast is needed because - // .default() expects the object type. - // The NESTED arrays get the same treatment as the object itself: a partial - // filter ({deny: […]}) must not materialize allow: [] beside it — an empty - // allow-list means deny-EVERYTHING, so the materialized default would turn - // a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only - // children) survives, since only the omitted key defaults to undefined. + // Preserve omitted filters and nested lists; an empty allow-list means deny all. toolFilter: z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 501b98b793..a47cfaa925 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -37,6 +37,6 @@ 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 scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. -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 Markdown prompt snapshot 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). +Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, and prompt snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness 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). +`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script can queue permission answers by stable option kind and can set session config options or assert their rejection. Missing permission answers cancel; selecting an unavailable kind fails the scenario. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index c4b909773d..a73a886457 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -39,7 +39,7 @@ Agent status (per agent): Model requests (on `llm/stream`): -- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the fold of the log's `request/header*` events (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing. +- **a loop-built request is exactly what the log reconstructs** — frozen requests with a live `sessionId` must match a fresh derivation bounded before the in-flight `step/start`, while non-content fields match the folded request headers. The check is prepended so ordinary short-circuiting stream listeners cannot skip it; correctness comes from the sequence boundary, not listener order. See the [reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). On any violation it throws `InvariantError` (`code: 'INVARIANT'`). diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f5ef6d2b4a..9f56e75ec5 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,19 +1,6 @@ /** - * Dev-mode invariants: a pure-listener plugin that asserts relationships in - * the harness event contract at runtime. - * - * Everything is a plugin — this is just listeners on `session/created`, - * `session/event`, `agent/status`, and the scoped dispatch and request seams. - * It is **off in production**: enable it in tests and demos, where a contract - * violation should be a loud failure rather than a subtle one. It doubles as - * executable documentation of the event taxonomy: the assertions below are - * the contract. - * - * Session owns immutable log storage: it snapshots and deep-freezes every - * accepted event at the source. This plugin checks relationships that one - * event's types and immutability cannot express, including turn/step nesting, - * scoped dispatch, status transitions, and request reconstructability. - * + * Dev-only listener plugin for cross-event lifecycle, scope, and request + * invariants that types cannot express. * @module @deepseek-ai/dsh-invariants */ diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 5c26b1f4d7..bcaf93bd5f 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -803,12 +803,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => { describe('request cross-check ordering (prepend)', () => { it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => { - // The replay adapter returns its chunks WITHOUT calling next(), which - // would silence a later-registered check — snapshot compositions load - // replay before the app bundle that loads invariants. The check prepends, - // so it fires ahead of append-registered listeners regardless of load - // order. (Prepend orders it against APPENDED listeners only; correctness - // rests on the seq-bounded rebuild, not on listener timing.) + // The prepended check must run before a short-circuiting replay listener. const ctx = new Context() await ctx.plugin(SessionStore) ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 913664cf2d..22dfdd624f 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -46,15 +46,7 @@ export interface ReplayConfig { childFiles?: string[] } -/** - * One recorded session's replay script: the per-call entries plus the header - * facts needed to ORDER and key it. Live session ids are freshly random at - * replay time and never equal the recorded `id`, so the recorded id is only a - * diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it - * (a parent is created before its children) and each newly-seen live session is - * bound to the next script in that order (= first-call order in the synchronous - * nested cut, where the parent streams before it delegates). - */ +/** Recorded calls plus header facts used to order parent and child replay scripts. */ export interface SessionScript { /** The recorded session id (diagnostics only — the live id differs). */ recordedId: string @@ -90,11 +82,7 @@ export function parseSessionLog(text: string): SessionEvent[] { } /** - * Read the identifying facts off a session log's header line (line 0): the recorded session - * `id` (diagnostics), `createdAt` (the deterministic ordering key that binds a recorded script - * to a live session — see {@link SessionScript}), and `seedLength` (the seed boundary — how - * many leading events were INHERITED via a fork seed rather than produced by this session's - * own model calls; absent ⇒ 0). + * Read replay identity, ordering, and fork-seed facts from the JSONL header. * * @param text - the raw `.jsonl` file contents (only the header line is read). * @returns the header's `id`, `createdAt`, and `seedLength`, defaulted when absent. diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 55065b1540..54417aa9af 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -21,12 +21,7 @@ const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } -/** - * A scripted provider: every {@link start} returns a ready run whose `result` - * resolves on the next task with the configured reply (and a structured value - * when the request asked for one and the capability is on). The required - * signal and `dispose()` both flip an unsettled result to `aborted`. - */ +/** Scripted provider whose configured result aborts if disposed or signalled first. */ class MockSubagentProvider implements SubagentProvider { readonly capabilities: SubagentCapabilities readonly inheritsParentContext: boolean diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index d3195e3f2b..478b236ab4 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. +The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -26,60 +26,47 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: |---|---|---| | `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` | | `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected | -| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load | +| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events | | `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) | | `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) | -| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) | +| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents | | `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice | -| `session/request_permission` | `approval/request` listener | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" | +| `session/request_permission` | `approval/request` listener | answers one-shot requests for bridge-owned calls and delegates others | | `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" | ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. +Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). ## Session config options The bridge advertises `sandbox-mode` and `approval-policy` only when their services are composed. Current values fold from each session's log over the composition default, so load restores overrides directly. `session/set_config_option` validates against the closed vocabulary, calls the domain writer, and returns refreshed state. Changes inside an open turn append immediately; idle changes are coalesced in memory and anchored at the next `agent/prompt-submit`, preserving turn enclosure and event order. A crash before anchoring discards the pending change, and load reports durable log truth. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). -Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload. +Background bash tasks use the session id as an opaque owner token, so one session cannot inspect or stop another's task. That contract belongs to [`dsh-tool-bash`](../../bash/tool-bash/). ## Per-session cwd -Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.) +`session/new` records the request's absolute cwd in the session header. `session/load` requires an absolute request cwd matching persisted metadata and rejects missing or mismatched metadata before constructing an agent. Bash defaults to that workspace; an explicit relative workdir resolves against it. `additionalDirectories` remains unsupported. ## Tool-call presentation -How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards: - -- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along). -- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). -- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. - -`presentResult` returns a generic, terminal, or diff card. The bridge switches on `view.card`; absent presentation falls back to a generic card without inspecting the tool name. Foreground bash uses terminal cards, filesystem writes and edits use diff cards, and reads use generic cards with locations. File-card titles are relativized against the session cwd, while `locations` and diff paths remain raw so clients can open the real file. Result content replaces the pending call card, so successful mutations always provide their final diff. - -The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. +Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation). ## Terminal card (capability-gated) -A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: - -- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card. -- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. - -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). +When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata; result text is omitted because ACP updates replace call content. Other clients receive a generic card and fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). ## Settle-exactly-once -A `session/prompt` resolves or rejects exactly once from the canonical `session/event` stream. The listener captures the prompt's owning turn from `turn/start` and settles in a `finally` block when the matching `turn/end` is appended, so a presentation/streaming failure cannot strand the RPC after the durable terminal event exists. Correlation by turn id prevents a late end from a cancelled prompt from settling its successor. A turn ending in `error` rejects the RPC with an internal error carrying the failure message because ACP has no error stop reason; every other reason resolves through the codec. An empty or whitespace-only prompt is rejected before enqueue because it would start no turn and otherwise leave the RPC pending. +A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue. ## Permission prompts -The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority. +For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge. ## Disposal & disconnect -Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise). +Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`. ## Known limitations (tracked TODOs) diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index c84333ff40..a26ded7b3b 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -36,13 +36,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { } /** - * Translate a harness {@link ContentBlock} from a prompt into ACP content for - * replay, or `undefined` for block kinds the bridge does not surface to the - * client as message content. Today only `text` maps; `resource_link` is an - * ACP prompt-only input rendered into text by {@link acpPromptToText}; - * `reasoning` is surfaced via `agent_thought_chunk` - * streaming rather than as a message block, and `tool-call`/`tool-result` - * are handled by the tool-call update path. + * Map replayable text to ACP message content. Other block kinds use their + * prompt, thought-stream, or tool-update paths. * @param block - the harness content block to translate. * @returns the ACP block, or `undefined` for a kind with no message-content mapping. */ diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5a5ce9e51f..c252e939a0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,37 +1,7 @@ /** - * The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that - * exposes the harness agent as an ACP server over JSON-RPC stdio, so editors - * (Zed and other ACP clients) can drive it. The structured analogue of the - * readline `stdio-chat` plugin. - * - * This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes - * the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, - * and `dsh-session-persistence` (for `session/load`). It maps: - * - * - `initialize` → protocol-version negotiation, text-only capabilities - * - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })` - * - `session/load` → `ctx.agents.resume(...)` then replay the event log - * - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn - * that ends in `error` rejects the RPC) - * - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a - * running step, clears queued + steering work, and drops a - * turn about to start) + settle the in-flight prompt - * - * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an - * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every - * `session/event` and `agent/*` event is routed strictly to its owning session - * record, so two sessions streaming at once never interleave their - * `session/update` notifications. Permission prompts ride the same ownership - * map: the bridge answers `approval/request` for its own agents over - * `session/request_permission` (see the approval answerer below) — whether a - * call ASKS is policy (a hook or plugin returning `ask`), not the bridge's. - * - * stdout is the protocol: this plugin must run in an example that loads NO - * stdout logger (the console logger writes to stdout and would corrupt the - * JSON-RPC frames). The guarantee is config-only — see the package README and - * RFC 010 § Risks. - * + * Multi-session ACP server bridge over JSON-RPC stdio. Creates or resumes + * agents, routes their events, settles prompts by turn, and answers approvals. + * Stdout is reserved for protocol frames. * @module @deepseek-ai/dsh-acp */ @@ -102,30 +72,15 @@ import { } from './codec.ts' export const name = 'acp' -// The bridge programs against the interface packages only (architecture rule: -// plugins never depend on dsh-agent-loop). `sessionPersistence` is required -// because `initialize` advertises `loadSession: true`. `tools` lets a tool own -// how its calls render (`presentCall`/`presentResult`); the bridge looks up the -// definition by name and falls back to a generic presentation when absent. +// Interface services required by advertised ACP capabilities. export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] -/** - * Build an ACP "invalid params" error whose human detail rides in the message. - * `RequestError.invalidParams(data, additionalMessage)` keeps the standard - * "Invalid params" message and appends `additionalMessage`, so we pass the - * detail as `additionalMessage` (and no structured `data`). - */ +/** Build an ACP invalid-params error with visible human detail. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** - * Build an ACP "internal error" whose human detail rides in the message. Used - * to reject a `session/prompt` whose turn ended in failure: a plain `Error` - * thrown from a method handler is flattened to a generic "Internal error" on - * the wire, so we wrap the detail in the SDK's `RequestError.internalError` - * (which appends `additionalMessage`) to surface *why* the turn failed. - */ +/** Build an ACP internal error with visible human detail. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -248,13 +203,7 @@ function stringArrayContent( export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override for tests; production uses stdio. */ stream?: Stream } @@ -262,70 +211,23 @@ export const Config: Schema = Schema.object({ model: Schema.string(), }) -/** - * Per-session bridge state. One per live ACP session; held in the `sessions` - * map keyed by id (RFC 011 multi-session). - */ +/** Per-session bridge state keyed by ACP session id. */ interface SessionRecord { sessionId: SessionId agent: Agent - /** - * The owned-agent disposer (from the {@link AgentHandle} the factory returned). - * Teardown calls it to unregister this ONE agent, stop its loop, await - * quiescence, and remove its session — instead of leaving it for the bridge - * fiber to reclaim. - */ + /** Owned-agent disposer that reaches per-session quiescence. */ dispose: () => Promise - /** - * Resolves tool-owned presentation for THIS session's tool calls and remembers - * each in-flight call's `(name, args)` so the matching `tool/result` can find - * its tool. Per-session so two concurrent sessions never cross their in-flight - * tool state. - */ + /** Per-session tool presenter and in-flight call correlation. */ presenter: ToolPresenter - /** - * Whether THIS session renders shell tools as terminal cards — snapshotted - * from the client's `_meta.terminal_output` capability at session creation - * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per - * session means the `tool_call` (which registers the terminal) and the matching - * `tool_call_update` (which streams its output) ALWAYS agree, even if a later - * `initialize` mutates the connection-level capability between them — otherwise - * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal, - * result terminal) or clobber the card (call terminal, result non-terminal). - */ + /** Session-creation snapshot of terminal-card support for call/result consistency. */ terminalEnabled: boolean - /** - * The in-flight `session/prompt`, or `undefined` when none is pending. A - * prompt resolves with a {@link StopReason} or rejects with an Error (a - * turn that ended in failure). Settled exactly once by its matching - * `turn/end`, direct cancellation, or teardown. - * - * `turn` is the loop turn number this prompt owns, captured from the log's - * `turn/start` after `send()`. Until then it is `undefined` (the turn has not - * begun). Only a `turn/end` whose turn number equals `turn` settles the prompt - * — so a *previous* prompt's late `turn/end` (e.g. an aborted turn whose end - * arrives after the next prompt is already installed) can never settle the - * wrong prompt. A direct cancel/dispose settle clears the whole in-flight slot, - * so a later stale `turn/end` finds no pending prompt. - * - */ + /** In-flight prompt and its captured turn number for exact settlement. */ inflight: { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined } | undefined - /** - * Config switches accepted while the session was IDLE, not yet anchored in - * its log. The turn-enclosure contract makes a bare between-turns append - * invalid (the JSONL backend treats a post-`turn/end` tail as crash - * garbage, and dev invariants throw), so an idle switch waits here and is - * anchored at the next turn's prompt-submit — before anything in that - * turn assembles a prompt or runs a call, and last write - * per knob wins (an idle flip-flop anchors as one event). Until anchored, - * the switch lives only in bridge memory: the set/new/load responses - * overlay it truthfully, and a restart before the next turn reverts it — - * which `session/load` then reports honestly from the log's fold. - */ + /** Idle config changes awaiting a turn-enclosed log anchor; last write wins. */ pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy } } @@ -336,41 +238,23 @@ interface SessionRecord { * correlation in a `finally` so presentation failure cannot starve settlement. */ export function apply(ctx: Context, config: AcpConfig): void { - // Capture the injected services NOW, during apply(), while we are inside this - // plugin's fiber (where `inject` grants access). The ACP method handlers run - // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is - // NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` / - // `ctx.sessionPersistence` lazily inside a handler throws "cannot get property - // … without inject". Resolving the references here and closing over them keeps - // the handlers working regardless of which fiber later invokes them. + // Capture injected services while executing inside this plugin's fiber. const agents = ctx.agents const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools const userInteraction = ctx.userInteraction - // A new ToolPresenter per session (and a throwaway per load replay), each given - // this warn sink so a throwing tool presenter is logged, not propagated. + // Presenter failures are logged and contained per session or replay. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) - // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId - // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). - // The two stay in lockstep: a record is added to `sessions` and the agent to - // `bySession` together, and removed together. + // Keep forward and reverse session indexes in lockstep. const sessions = new Map() const bySession = new WeakMap() - // Session ids whose `session/load` is mid-`resume()` (the slot is reserved - // before the async resume so a pipelined load/new for the SAME id can't create - // two agents). Distinct ids load concurrently; a given id loads once at a time. + // Reserve ids across asynchronous resume; distinct ids still load concurrently. const loadingIds = new Set() - // Set once the bridge has torn down (disposal or client disconnect). An async - // `session/load` mid-`resume()` when teardown ran must observe this after its - // await and NOT install a record (which would resurrect a live agent/listeners - // after the bridge closed). Checked after every load await. + // Post-await checks prevent a closing bridge from publishing resumed sessions. let closed = false - // Whether the client advertised the Zed `_meta.terminal_output` capability in - // `initialize`. When true, a tool's terminal presentation is rendered as a - // terminal card (content + `_meta.terminal_*`); when false, the bridge uses - // the tool's text fallback. Set once in `initialize`, read on every tool event. + // Connection-level capability copied into each new session record. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -1140,12 +1024,7 @@ export function streamSessionEventUpdate( } /** - * Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires - * `content` + `priority` + `status`, but a {@link TodoItem} carries no priority, - * so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the - * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole - * plan on each `plan` update, matching the harness's whole-list-replace - * semantics, so no per-entry diffing is needed. + * Map a whole harness todo list to an ACP plan, assigning medium priority. * @param todos - the harness todo list (the whole list, not a diff). * @returns the ACP plan body, one entry per todo. */ @@ -1153,14 +1032,7 @@ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } } -/** - * Per-connection terminal-rendering context threaded into - * {@link streamSessionEventUpdate}: whether the client advertised the - * `_meta.terminal_output` capability, and the session's workspace cwd (the - * default terminal-card header when a tool doesn't supply its own). Kept out of - * the pure translator's required params so the no-capability / no-presenter - * tests stay terse. - */ +/** Terminal-card capability and workspace context for event rendering. */ export interface TerminalRendering { enabled: boolean /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */ @@ -1171,59 +1043,30 @@ export interface TerminalRendering { const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } /** - * Resolves tool-owned presentation for a session's tool-call events. A tool - * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a - * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up - * by name in the registry and applies a generic fallback when a tool defines - * neither. The returned view is what {@link streamSessionEventUpdate} switches on. - * - * The `tool/result` session event does NOT carry the tool name or args — so to - * call a tool's `presentResult` (which needs both), the presenter remembers each - * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the - * matching result. The map is bridge-LOCAL (not a change to the event schema or a - * core service): one presenter per live session - * (and a throwaway per `session/load` replay), and each entry is removed when its - * result arrives. In the normal loop a `tool/call` is always followed by a - * `tool/result` (the registry turns even a thrown tool into an isError result), - * so the map holds only currently-in-flight calls. The one exception is a step - * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can - * leave a single stale entry per such call; this is bounded by the session - * lifetime (the whole presenter is dropped on teardown) and never affects - * correctness — a later result for a different callId is unaffected, and the - * stale entry's only cost is one map slot until the session ends. + * Resolve tool-owned call/result views with generic fallbacks. Per-session + * call-id state supplies the tool name and arguments omitted from result events. */ export class ToolPresenter { private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. - * @param onError invoked when a tool's `presentCall`/`presentResult` THROWS; - * the presenter swallows the error and falls back to the generic - * presentation so a buggy display callback can never fail a live turn or a - * `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the - * boundary"). Defaults to a no-op for callers that don't supply a logger. + * @param onError receives contained presenter failures before generic fallback. */ constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, - /** - * The agent whose view resolves tool presentations: a scoped/shadowed - * tool presents with ITS OWN presentCall/presentResult — the same - * definition that executed — not a same-named global's. Absent (a replay - * with no live agent) the global view presents. - */ + /** Agent scope for tool lookup; absent during replay without a live agent. */ private readonly agent?: Agent, ) {} /** - * Pending-state render intent for a `tool/call`; remembers `(name, args, card)` - * for the matching result. + * Resolve a pending call and remember its state for the matching result. * @param callId - the call id the matching `tool/result` will look up. * @param name - the tool name, resolved against the registry for `presentCall`. * @param argsJson - the raw arguments JSON from the event; parsed for the view * (a non-JSON string is surfaced raw). - * @returns the tool-owned view, or the generic fallback (title = tool name, - * kind `other`, parsed args as raw input) when the tool defines none or threw. + * @returns the tool-owned view, or a generic parsed-input fallback. */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) @@ -1245,16 +1088,12 @@ export class ToolPresenter { } /** - * Completed-state render intent for a `tool/result`; consumes the remembered - * `(name, args, card)`. - * @param callId - the id of the matching `tool/call`; an unknown or late id - * falls back to the raw content. + * Resolve a completed result and consume its remembered call state. + * @param callId - matching call id; unknown or late ids use raw content. * @param content - the result's content blocks (the fallback and fill-in body). * @param isError - whether the result is an error, forwarded to `presentResult`. * @param meta - the result's machine-readable meta, forwarded when present. - * @returns the tool-owned view — an orphaned `terminal` result (no terminal - * call side) and a content-less `generic` are normalized — or the raw-content - * generic card when the tool defines no `presentResult` or threw. + * @returns the normalized tool-owned view, or a raw-content generic fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) @@ -1324,25 +1163,11 @@ type AcpToolCallContent = | { type: 'diff'; path: string; oldText: string | null; newText: string } | { type: 'terminal'; terminalId: string } -/** - * Relativize a file card's TITLE path against the session workspace cwd, so a - * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the - * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the - * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The - * pure tool presenter can't see the session cwd, so this happens here where the - * bridge knows it. The rewrite is an exact substring replace of the known raw - * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a - * heuristic. A path outside the workspace, or an absent/relative session cwd, is - * left unchanged. - */ +/** Relativize an in-workspace file path in a card title; keep target paths raw. */ function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // Only relativize a target that stays INSIDE the workspace. `relative` prefixes - // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone - // or `..…`), NOT a bare `..` char prefix, so a sibling like `..cache/x` - // (a real in-workspace name) still relativizes. Never relativize to the empty - // string (rawPath === cwd — a non-file target). + // Reject an empty relative path or a leading parent-directory segment. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index f02a7b0649..6b77f3ab49 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -24,22 +24,16 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Dispose the whole context. The bridge's teardown must abort the agent and - // AWAIT whenIdle() — so right after dispose resolves, the agent is settled - // (not still running). Proves disposal waited, not just requested. + // A resolved teardown is the quiescence boundary. await harness.ctx.fiber.dispose() expect(agent.status).not.toBe('running') - // The in-flight prompt settled (cancelled) rather than hanging forever. const res = await promptDone expect(res.stopReason).toBe('cancelled') }) it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => { - // Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop - // stay up and the transport is still live. A late session/new must hit the - // `closed` guard and reject — NOT create an agent the disposed bridge can no - // longer stream or settle. Verify the world: no agent appeared. + // An ACP-only unload must close creation while shared services remain live. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -51,14 +45,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => { - // The factory (`ctx.agents.create`) is reached through the bridge's - // traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)` - // registration binds to the CALLER context — the bridge fiber — not the - // AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload) - // must therefore reclaim the agent's registry entry, even though agents/ - // agent-loop stay up. This pins the fiber-ownership the bridge's teardown - // doc comment relies on; if a refactor rebinds the registration to the - // AgentLoop fiber, the agent would survive bridge dispose and this fails. + // The caller fiber owns agents created through its traced service proxy. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -70,10 +57,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => { - // After teardown (here a client disconnect sets `closed`), a late - // `session/new` must NOT create an orphan agent the bridge can no longer - // drive/settle. The transport is gone so the RPC rejects; assert the world: - // no new agent appeared in the registry. + // Assert registry state because the closed transport rejects the RPC. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -85,35 +69,21 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { - // The ACP transport closes (editor quits) while a turn runs. The bridge must - // settle the in-flight prompt cancelled and DISPOSE the agent (the session's - // per-agent AgentHandle teardown) rather than leaving an orphaned running — - // or even idled-but-still-registered — agent whose updates are swallowed. + // Disconnect must dispose, not merely idle, the owned agent. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const agent = harness.ctx.agents.get(AgentId(sessionId))! - // Start a prompt that hangs in the model stream. The prompt RPC will never - // return (its transport is severed), so do not await it. + // The transport will close before this hanging RPC settles. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Sever the transport — the bridge's conn.closed teardown runs and drives the - // agent's AgentHandle dispose to quiescence on its OWN (before any dispose()). await harness.closeClientTransport() await agent.whenIdle() - // The agent's loop has stopped: status `disposed`. expect(agent.status).toBe('disposed') - // Await the bridge teardown to completion WITHOUT tearing down the root - // agents/sessions services (so we can still query them). acpFiber.dispose() - // invokes the SAME memoized quiesce() the disconnect started and awaits its - // promise — which resolves only after every rec.dispose() (loop exit + - // session removal) has finished, closing the whenIdle()/owned.dispose() - // microtask race. The AgentHandle dispose has run: the agent is unregistered - // and its session removed from the store, not merely idled (the old - // behavior). The services live on the root ctx, so they survive this. + // The shared bridge teardown also removes registry state. await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() @@ -121,10 +91,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { - // conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously. - // They must share one teardown promise: dispose() must NOT return before the - // disconnect teardown's whenIdle() has settled (a `record === undefined`-only - // guard would let the second caller return early mid-teardown). + // Both teardown callers must await the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -133,11 +100,9 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // Fire both teardown paths without awaiting the first, then await both. const close = harness.closeClientTransport() const dispose = harness.ctx.fiber.dispose() await Promise.all([close, dispose]) - // After BOTH settle, the agent has fully drained (not still running). expect(agent.status).not.toBe('running') }) @@ -157,14 +122,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { - // The teardown-ORDER guarantee: a per-agent dispose must stop the loop, - // AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire - // through the still-attached store observer → `session/event`), and only - // THEN remove its publication hooks and session entry. If the order were inverted - // (detach first), the closing events would never reach persistence. Drive a - // CLEAN turn to completion, dispose JUST the bridge, then re-load the - // persisted log from disk and assert the closing turn/end is on disk — the - // world, not the agent's self-report. + // Reload from storage to verify final flush precedes session detach. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -172,12 +130,9 @@ describe('acp bridge — disposal & HMR safety', () => { const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) - // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() - // Re-load the session from disk: every live event (incl. the closing - // turn/end) was flushed before the session was detached. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) expect(reloaded.events.length).toBe(liveEvents) const last = reloaded.events.at(-1)! @@ -186,18 +141,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { - // The teardown-order contract only earns its keep when the closing events are - // produced BY the dispose itself. Here the model stream HANGS, so the turn is - // still open when teardown runs: the composite agent effect stops the loop, - // the loop unwinds and appends `turn/end {disposed}` + runs its final - // `session/flush` — all while the store-owned publication hooks are still attached (the session - // detach is the LAST disposer in the same effect's LIFO chain) — and only - // THEN is the session detached. If the order were inverted (or the session - // were a racing SIBLING effect), the abort-produced `turn/end` would never - // reach disk and a re-load would instead show crash-recovery's synthetic - // `interrupted` closer. Re-load from disk and assert the REAL `disposed` - // reason landed — proving the loop's own closing event was captured, not a - // recovered substitute. + // A mid-turn dispose must flush its real closer before detaching storage. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -205,16 +149,11 @@ describe('acp bridge — disposal & HMR safety', () => { void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // The turn is OPEN in the log (turn/start appended, no turn/end yet). const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length - // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered - // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() - // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not - // self-report) — NOT a crash-recovery `interrupted` substitute. const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId)) const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end') expect(persistedTurnEnds.length).toBe(openTurnEnds + 1) @@ -223,11 +162,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { - // The factory returns a per-agent AgentHandle whose dispose() tears down - // EXACTLY that agent + its session — RFC 011 isolation. Create two agents - // directly through the registry factory (the same path the ACP bridge uses), - // dispose one handle, and assert the other survives, registered and - // queryable, with its session still in the store. + // Dispose one handle and assert the sibling remains published. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, @@ -239,11 +174,9 @@ describe('acp bridge — disposal & HMR safety', () => { expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) await handleA.dispose() - // A is gone — unregistered AND its session removed from the store. expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') - // B is wholly unaffected. expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') @@ -251,14 +184,7 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { - // The AgentHandle teardown folds session-detach, register, and loop-stop - // into ONE composite effect whose disposers run as a `.then()` chain. The - // register disposer emits `agent/disposed`; if a listener throws and the - // emit is UNCONTAINED, the rejected chain skips the LATER session-detach - // disposer — stranding the session in the store with its publication hooks attached (a - // leak AND a durability hole, since the new design relies on detach - // running). The emit must be contained. Register a throwing listener, drive - // a clean turn, dispose, and assert the session was STILL removed. + // Listener failure cannot skip the later session-detach disposer. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ @@ -268,7 +194,6 @@ describe('acp bridge — disposal & HMR safety', () => { await handle.agent.whenIdle() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined() - // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran @@ -276,18 +201,12 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { - // The handle's dispose() must memoize: the underlying cordis effect disposer - // is single-shot, so a second dispose() while the first is mid-teardown would - // otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the - // first call's await agent.done + final flush finished. Every caller must - // observe the same quiescence boundary. + // Concurrent callers must share the in-flight teardown promise. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) - // Drive a turn that hangs in the model stream, so the loop is mid-turn when - // disposed — its exit runs a final session/flush we can gate to hold the - // teardown observably in-flight. + // Gate the final flush to keep teardown observably in flight. handle.agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') @@ -295,22 +214,18 @@ describe('acp bridge — disposal & HMR safety', () => { const flushGate = new Promise((resolve) => { releaseFlush = resolve }) harness.ctx.on('session/flush', () => flushGate) - // First dispose enters teardown (aborts the hanging step) and blocks in the - // gated final flush. const first = handle.dispose() let firstSettled = false void first.then(() => { firstSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(firstSettled).toBe(false) - // Second dispose MUST await the same in-flight teardown, not resolve early. const second = handle.dispose() let secondSettled = false void second.then(() => { secondSettled = true }) await new Promise(r => setTimeout(r, 20)) expect(secondSettled).toBe(false) // memoized: still pending with the first - // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index f559ca36d9..9c7da399de 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -124,11 +124,7 @@ describe('acp bridge — turn outcomes', () => { }) it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => { - // Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output` - // capability in initialize. The bridge must then emit the terminal CARD: the - // description content block THEN a terminal content block + `_meta.terminal_info` - // (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the - // result — and OMIT the update's text content (it would clobber the card). + // Terminal capability moves output to card metadata. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -164,11 +160,7 @@ describe('acp bridge — turn outcomes', () => { }) it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => { - // The session is created with the capability ON. A SECOND initialize then - // turns it OFF at the connection level — but this session keeps its snapshot, - // so its bash call STILL renders as a terminal card (call + result agree). - // Without the snapshot, the result path would re-read the now-OFF capability - // and either clobber the card (content sent) or be inconsistent with the call. + // Session creation snapshots the capability for both call and result. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -324,11 +316,7 @@ describe('acp bridge — turn outcomes', () => { }) it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => { - // Over the async JSON-RPC transport the loop usually wakes before cancel - // arrives, so this is a running/mid-step cancel (the synchronous pre-step - // DROP is unit-tested in agent-loop/cancel.spec.ts). The ACP-level guarantee: - // the prompt settles cancelled, the agent reaches idle, and no second/leaked - // turn runs afterward. + // The cancelled prompt must not leave queued work for another turn. harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] }) const sessionId = await newSession(harness) const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -337,18 +325,12 @@ describe('acp bridge — turn outcomes', () => { expect(res.stopReason).toBe('cancelled') const agent = harness.ctx.agents.get(AgentId(sessionId))! await agent.whenIdle() - // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so - // no second turn was batched or leaked. (A best-effort abort that left queued - // work could have started a second turn.) const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length expect(turnStarts).toBeLessThanOrEqual(1) }) it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => { - // The ACP bridge settles the cancel RPC synchronously and accepts the next - // prompt WITHOUT awaiting quiescence — so this drives cancel→prompt with NO - // whenIdle() between, the production race. An idle cancel must be a no-op that - // does NOT drop the following prompt. + // Exercise cancel→prompt without an intervening quiescence wait. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) // Cancel while idle (no prompt in flight) — a no-op. @@ -364,9 +346,7 @@ describe('acp bridge — turn outcomes', () => { }) it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => { - // Cancel a running turn, then send the next prompt WITHOUT awaiting quiescence - // (the synchronous-settle path). The new prompt must run — the cancel marker - // must not leak onto it. + // A cancel marker must not leak onto an immediate next prompt. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] }) const sessionId = await newSession(harness) const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] }) @@ -384,10 +364,7 @@ describe('acp bridge — turn outcomes', () => { }) it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => { - // Regression: prompt A runs; cancel settles A and frees the slot; A's - // aborted turn/end is still pending in the loop. Prompt B is sent before - // A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT - // settle B — B owns a later turn. B then completes on its OWN turn/end. + // Correlation must keep A's late turn/end from settling B. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] }) const sessionId = await newSession(harness) @@ -396,8 +373,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) expect((await a).stopReason).toBe('cancelled') - // Immediately send B; its turn (2) is distinct from A's (1). If A's late - // turn/end leaked onto B, B would settle 'cancelled' instead of 'end_turn'. + // B owns a later turn number than A. const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] }) expect(b.stopReason).toBe('end_turn') const text = harness.updates diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 8b52f44367..f5929ac363 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -30,12 +30,8 @@ export function resolveConfigPath( } /** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in - * `dir` (Node native `process.loadEnvFile`). An absent file is fine — the - * environment may already carry the variables; the leaf `cordis.yml` reads - * them via the `!!js` tag. A present-but-unreadable `.env` is a real - * misconfiguration: surface it via `warn` (one line, default stderr) rather - * than silently running with the wrong environment. + * Load the optional gitignored `.env` from `dir`. Missing files fall back to the + * ambient environment; other read failures are reported through `warn`. * @param binName - the diagnostic prefix on the warn line. * @param dir - the directory whose `.env` to load. * @param warn - sink for the one-line misconfiguration diagnostic. diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index c943f9b8ac..45b67527b9 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -32,14 +32,8 @@ async function pkgName(absDir: string): Promise { } /** - * Build a temp consumer dir: `node_modules` with the workspace + vendor packages - * symlinked in, a `src/` carrying the example mock backend, and a `cordis.yml` - * that wires them onto the stdio app. Returns the dir (caller removes it). - * - * `disabledBrokenEntry` appends an entry that points at a non-existent plugin but - * is marked `disabled: true`. The Loader leaves a disabled entry fiber-less by - * design, so it exercises that the fail-loud entry-load guard does NOT mistake a - * valid disabled entry for a failed import. + * Build a temporary symlinked consumer for the stdio app. The optional disabled + * broken entry verifies that load guards accept intentionally fiber-less entries. */ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise { const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index a50bffad0b..b3c2f5563b 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -1,13 +1,11 @@ # @deepseek-ai/dsh-user-approval -User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI. +Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated [Cordis catalog](../../../docs/cordis-catalog/events.md). -The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. `ApprovalRequest` is a readonly same-process contract: the service borrows the exact request, agent, session, and abort signal rather than cloning or freezing them. The request requires an open turn because the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event. +Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision. -The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates. +Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`). +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is exposed to the model through the prompt and a coalesced switch notice. -One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md). - -Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome. +The tools pipeline consumes this seam for `ask` decisions and the sandboxed bash tool uses it for escalated retries. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 6b3ef962bb..3225ac6336 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -1,35 +1,6 @@ /** - * Approval seam: `ctx.approval` answers exactly one question — "may this - * specific action proceed?" — by dispatching the `approval/request` waterfall - * to whatever answerers the deployment composed (an ACP editor prompt, an - * auto-decide policy, a scripted test listener) and returning a closed - * {@link ApprovalOutcome}. With no answerer the waterfall falls through to the - * built-in default `'unavailable'`: absence of a UI can never grant anything. - * - * The service is the MECHANISM (dispatch, cancellation, audit); answerers are - * the POLICY. It serves both ask paths the sandbox RFC names — the - * `tools/pre-execute` `ask` decision and the sandbox post-denial escalation — - * so every asker shares one outcome - * vocabulary and one audit trail. Grants are one-shot by design: an - * `'allowed-once'` outcome authorizes the single action it was asked about, - * never a class of future actions. - * - * Every request lands two log-only session events on the requesting agent's - * log (`approval/asked` / `approval/decided`, paired by - * {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the - * model-visible transcript: the model only ever sees the tool result the - * caller derives from the outcome. - * - * The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching): - * `effective = fold(the session's 'approval/policy' events, last one wins) - * ?? config.policy` — the session log is the store, so an override survives - * restart by replay. The service resolves `'never'` sessions to - * `'rejected'` inside `request()` before dispatching any answerer (no - * registration order, including a later `prepend`, can precede it); a prompt section states `'never'` - * (and only `'never'` — an availability promise is unknowable without - * asking); an `agent/pre-step` narrator explains a switch to the model in at - * most one coalesced notice per step. - * + * Approval request, cancellation, audit, and per-session policy seam. Missing + * answerers fail closed; grants apply only to the requested action. * @module @deepseek-ai/dsh-user-approval */ @@ -51,19 +22,9 @@ declare module 'cordis' { interface Events { /** - * Waterfall asking the composed answerers to decide one approval request. - * Dispatched only from {@link ApprovalService.request} — callers go through - * the service (which owns cancellation and the audit events), never through - * `ctx.waterfall` directly. A listener that can answer for this request's - * agent returns an outcome WITHOUT calling `next()` (the decision slot is - * single-occupancy, first listener to answer wins); a listener that does - * not recognize the agent MUST call `next()` so another answerer — or the - * fail-closed default `'unavailable'` — gets the question. Throwing is - * contained by the service and yields `'unavailable'`. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a - * listener registered through `agent.ctx` receives only that agent's - * questions, while a plain-context listener receives every agent's. - * `req` is a readonly same-process value borrowed from the caller. + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @param req - the pending decision (agent, tool identity, reason, signal). * @mode waterfall */ @@ -124,16 +85,8 @@ export function ApprovalRequestId(id: string): ApprovalRequestId { } /** - * The closed outcome vocabulary of one approval request. - * - * - `'allowed-once'` — a one-shot grant for exactly the asked-about action; - * consumed by proceeding, never a durable authorization. - * - `'rejected'` — an answerer (human or policy) said no. - * - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or - * the requesting execution aborted while the question was pending. - * - `'unavailable'` — nobody composed could answer (no listener, none that - * recognizes the agent, or an answerer failed). Callers MUST fail closed on - * it, exactly like `'rejected'` — the two differ only for audit and wording. + * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn + * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' @@ -218,14 +171,9 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean { } /** - * THE write path for a session's approval-policy override: appends exactly - * one `approval/policy` event — the switch IS its event; nothing mutates - * policy state out of band. Takes effect on the session's next ask and next - * prompt assembly (the consumers fold on every read). Rejects a value outside - * {@link APPROVAL_POLICIES} before appending anything. + * Append the sole durable representation of a session policy override. * @param session - the session the override belongs to. - * @param policy - the policy every subsequent ask for this session resolves - * under (until the next switch). + * @param policy - the policy in effect until the next switch. */ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void { if (!APPROVAL_POLICIES.includes(policy)) { @@ -235,13 +183,8 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi } /** - * One concrete permission question. Identifies the action precisely enough - * for an answerer to present it and for the audit events to reconstruct what - * was asked — it deliberately does NOT carry tool arguments: a UI answerer - * attaches the prompt to the already-streamed tool call via `callId` instead - * of re-rendering the call. This is a readonly same-process contract: - * `request()` borrows the request and its `agent` and `signal` capabilities - * directly rather than treating them as serialized input. + * Readonly same-process permission question. `callId` links to an already + * presented tool call, so arguments are not duplicated here. */ export interface ApprovalRequest { /** @@ -278,18 +221,9 @@ export interface Config { } /** - * The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the - * `approval/request` waterfall and audits every ask/outcome pair to the - * requesting agent's session log. Stateless between requests — grants are - * returned to the caller, never stored here. - * - * Owns the policy tier too (`effective = fold(the session's 'approval/policy' - * events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'` - * before dispatching any interactive answerer, a per-agent prompt section - * states a `'never'` policy (and only that one in prose — an `'ask'` promise - * could overclaim an answerer that headless compositions do not have), and an - * `agent/pre-step` narrator injects at most one coalesced notice when a - * session's effective policy moved past what the model was last told. + * Approval request and policy service. It logs each ask/outcome pair, applies + * session policy before answerers, and exposes deterministic policy changes to + * the model through prompt and pre-step notices. */ export class ApprovalService extends Service { static Config: z = z.object({ @@ -301,12 +235,7 @@ export class ApprovalService extends Service { const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) - // Visibility layer 1, scoped on the prompt registry so headless - // compositions mount the seam without it: state the one deterministic - // policy per session. 'ask' renders only a source-owned state marker — - // stating "you will be asked" would overclaim in a composition with no - // answerer. The marker, not deployment-controlled prose, is what the - // restart narrator reads back from the logged request header. + // State only deterministic policy; a marker records the otherwise silent state. ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.section({ name: 'approval:policy', diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index e4e54c60fc..feb332e5d8 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -6,15 +6,8 @@ */ /** - * The internal reason attached to a timeout abort so consumers can classify it - * after the fact. It carries the failing `code` (each capability's own string — - * `BASH_TIMEOUT`, `WEB_FETCH_TIMEOUT`, …) and the `timeoutMs` that elapsed. - * - * It is an INTERNAL classification reason, not a public error: providers - * translate it into their seam-specific error code or result field (via - * {@link timeoutOf}) before returning to callers. Native `AbortSignal.timeout()` - * yields a fixed `TimeoutError` indistinguishable across timeout kinds; this - * type is identifiable and carries the code/duration. + * Internal abort reason carrying a capability-owned code and elapsed deadline. + * Providers translate it through {@link timeoutOf} before returning to callers. */ export class TimeoutReason extends Error { override name = 'TimeoutReason' diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 95c07f0d2d..52b554fb55 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -1,43 +1,6 @@ /** - * The host half of one worker-engine run: spawn the Worker, bridge its child - * RPC onto the holder-bound subagent service, fan its observer messages into - * the engine's events, and own cancellation, the settle-within-grace - * guarantee, and child cleanup. The worker's lifetime IS the run's lifetime: - * `dispose()` always ends with `worker.terminate()`, so no thread outlives its - * run. - * - * The run's `result` promise settles exactly once, from whichever of these - * lands first: receipt of the worker's `result` message, an unexpected worker - * death (`error`/`messageerror`/premature `exit` → `stopReason: 'error'`, or - * `'cancelled'` when a cancel was in flight), or the post-cancel grace timer (a - * script that never settles is force-settled `cancelled` and its worker - * terminated — the real kill an in-process engine could not perform). At - * `result` receipt the host snapshots whether caller/signal/dispose - * cancellation is already in flight: an earlier cancellation overrides a - * non-cancelled report; otherwise the report wins before settlement-only child - * cleanup invokes arbitrary provider callbacks. Worker death uses the same - * boundary: it claims `error` (or a previously requested `cancelled`) before - * reaping children, so cleanup callbacks cannot rewrite the outcome. That - * first signal also closes inbound message admission: Node may emit `error`, - * then deliver queued messages, then emit `exit`, but those late messages may - * neither create work nor narrate after settlement. If Result or grace already - * owns the outcome, death preserves it while still cleaning resources; the - * eventual exit performs a final disposal-only sweep without repeating child - * cancellation. - * - * Provider starts and published children are tracked separately. Every start - * receives one shared per-run abort signal; the provider owns partial setup - * until its promise fulfills. If admission closes while a start is pending, - * the signal aborts it; a late fulfillment is disposed without publication to - * the worker. Ready runs enter a callId registry whose memoized disposal is - * shared by graceful worker RPC, public disposal, normal-settlement reap, and - * worker-death cleanup. Quiescence requires both pending starts and published - * children to drain. Lifecycle pairing is host-guaranteed independently: - * every forwarded `agent-start` enters a ledger, and a dead or terminated - * worker's missing `agent-end` is synthesized exactly once as cancelled. On a - * termination path `agentsStarted` reports the host-observed child-start count; - * calls still queued worker-side for a concurrency slot are unknowable. - * + * Host side of one workflow run. Owns the worker, child RPC, first-outcome + * settlement, cancellation grace, lifecycle pairing, and quiescent cleanup. * @module @deepseek-ai/dsh-workflow-workerthread/host */ @@ -63,29 +26,10 @@ interface ChildRecord { } /** - * Resolve the worker entry and spawn options for the current runtime shape. - * Unbuilt (tsx demos, vitest — `import.meta.url` points into `src/`), the - * entry is a JavaScript data-URL bootstrap. That bootstrap runs INSIDE the - * user worker, registers tsx's ESM AND CommonJS transforms there, and only - * then imports the TypeScript sibling. The whole mixed-module source graph - * therefore receives TypeScript transformation and the tsconfig paths map in - * the worker's own module-loader realm. A worker inherits no - * transform pipeline from vitest (vite transforms in-process), and a parent - * `--import tsx` registration is not a contract that user workers share on - * every supported Node line. Built (`lib/index.js`), the entry is the sibling - * bundle the package tsdown config emits and no loader is needed (`execArgv` - * pinned empty in both shapes — hermetic, like the environment). - * - * Both shapes spawn with an EMPTY environment (`env: {}`): the documented vm - * escape reaches `process`, and the harness's ambient credentials - * (`DEEPSEEK_API_KEY` et al.) must not ride along — the same stance as - * `dsh-code-runtime-worker`, stronger than the scrubbed env the - * defensive-patterns rule requires for spawned commands (a shell needs PATH; - * this worker needs nothing). Sole exception: the unbuilt shape forwards - * `TSX_TSCONFIG_PATH` when the parent carries it (loader plumbing the paths - * map depends on outside the repo cwd, not a secret). This closes the - * AMBIENT channel only — an escapee still holds process-wide privileges - * like fs access (the README's trust premise stands). + * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx + * transforms inside the worker. Both shapes clear `execArgv` and the ambient + * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path + * resolution. * @param init - the run payload, passed as `workerData`. * @returns the entry URL and the Worker options to spawn it with. */ @@ -94,15 +38,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOpti if (!import.meta.url.endsWith('.ts')) { return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } } - // Resolve tsx lazily: only the unbuilt shape executes this arm, so a built - // consumer never needs the dev-only loader installed. A JavaScript entry is - // essential — it can install tsx's ESM and CommonJS hooks from INSIDE the - // user worker before any TypeScript enters Node's native strip-only parser. - // Both hooks are load-bearing because the source graph crosses both module - // shapes on supported Node lines. TSX_TSCONFIG_PATH is - // the one variable forwarded through the scrub: a parent running outside - // the repo cwd (the ACP snapshot harness is the real case) pins the paths - // map through it. Loader plumbing, not a secret. + // Resolve tsx only for unbuilt consumers and install it before importing TS. const workerEntry = new URL('./worker.ts', import.meta.url) const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api') const tsxCjsApiEntry = import.meta.resolve('tsx/cjs/api') diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 94282cb173..6905c51e85 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,39 +1,7 @@ /** - * Per-run execution state for the engine's THREAD side: the script's vm - * context and its injected hooks (`agent`/`parallel`/`pipeline`/`phase`/ - * `log`/`args`), the concurrency semaphore and caps, cancellation, and the - * drive loop that turns a script settlement into a {@link WorkflowResult}. - * Children are started by RPC to the host through a {@link ChildPort}, so - * this module never touches a cordis context — it runs inside the worker - * thread. - * - * Value boundary (the trust premise lives in ./realm.ts): values ENTERING the - * worker-side host code from the script (hook options, schemas, the return - * value) are materialized by `materializeFromRealm` — a plain walk that - * rejects loud everything JSON cannot carry, which also makes every value - * safe for the later postMessage hop. Values ENTERING the realm (`args`, - * `agent()` results, hook promises and their failures, combinator arrays) are - * handed over DIRECTLY as worker-realm values: the script is model-written - * and trusted, so outer prototypes are not a leak. `args` is cloned once at - * start so a script scribbling on it cannot mutate the session's init object - * (a benign-bug guard; the postMessage clone already isolated the caller). - * - * Failure discipline: fatal {@link WorkflowError}s (bad hook arguments, - * unsupported options/schemas, tripped caps, synchronous start refusal, - * provider-start failure, ready-child result rejection, and - * cancellation) ALWAYS propagate through - * `parallel`/`pipeline` — recognized by `instanceof` against this realm's - * class, which a script inside the vm context cannot forge — and the per-item - * `null` is reserved for child-run failures and ordinary in-stage script - * errors. Every hook-returned promise gets a no-op rejection consumer, so a - * dropped promise cannot surface an unhandled rejection (which would kill the - * worker and read as an engine fault). - * - * There is deliberately NO worker-side abandon channel: a script that never - * settles after a cancel simply never posts a result, and the HOST enforces - * the settles-within-grace guarantee by force-settling `cancelled` and - * terminating the worker — the real kill an in-process engine could not have. - * + * Worker-side workflow runtime: vm hooks, child RPC, limits, value + * materialization, cancellation, and result shaping. Host termination enforces + * the cancellation deadline. * @module @deepseek-ai/dsh-workflow-workerthread/runtime */ diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 4ad00ee02f..1862026a93 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -16,28 +16,10 @@ function fakeParent(): Agent { return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent } -// Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on -// every start): on a contended CI runner it regularly blows past vitest's 5s -// default test timeout, observed repeatedly on the coverage lane. +// Allow cold worker startup on contended CI runners. vi.setConfig({ testTimeout: 30_000 }) -/** - * `vi.waitFor` with a contention-proof default timeout: the 1s default - * flaked repeatedly on the CI coverage lane, where worker-thread cold start - * (CPU-bound — a fresh thread compiles the runtime) competes with three - * sibling vitest workers for CPU. The 10s default is for exactly those - * races — waiting for a worker to start, run its first script line, or - * deliver an async child-registration message to the host. It is NOT for a - * wait that asserts the HOST reacted PROMPTLY to something that already - * happened (a settled result, an observed worker death): those keep an - * explicit tight override below, or the generous default would silently - * accept a multi-second regression in host-side reap latency as passing - * (proven by injecting a 6s delay into one such reap and watching the - * un-overridden version of this helper still pass in ~6s). - * @param assertion - retried until it stops throwing or the timeout elapses. - * @param timeout - override for a wait that must stay deliberately tight. - * @returns resolves when the assertion passes. - */ +/** Retry an assertion until it passes or the timeout elapses. */ function waitFor(assertion: () => void, timeout = 10_000): Promise { return vi.waitFor(assertion, { timeout, interval: 50 }) } diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 0fa0289b41..ba366a8c66 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -1,21 +1,6 @@ /** - * The workflow capability seam (`ctx.workflows`): an abstract service defining - * WHAT a workflow engine does — execute a model-written orchestration script - * that fans out subagents — without saying HOW. Implementations subclass - * {@link WorkflowService} and register as the `workflows` service (one - * implementation per context, cordis' standard duplicate-service behavior); - * the implementation is `@deepseek-ai/dsh-workflow-workerthread`, which runs each - * script in its own worker thread. Hardened engines (an isolated-vm or - * separate-process sandbox) swap in without touching the model-facing tool - * that consumes them (`@deepseek-ai/dsh-tool-workflow`). - * - * The `workflow/*` lifecycle events are OBSERVE-ONLY data: they - * carry {@link WorkflowRunInfo} (id + meta), never the live {@link WorkflowRun} - * — a listener must not gain `cancel`/`dispose`; control stays with the - * `start()` caller holding the run. Same-process payloads are borrowed - * immutable values. Every listener is independently contained, so a throw or - * rejected promise can neither strand a run nor starve peers. - * + * Workflow capability seam. Implementations execute orchestration scripts; + * observe-only lifecycle events never expose run control. * @module @deepseek-ai/dsh-workflow */ @@ -116,29 +101,7 @@ export type WorkflowEventName = | 'workflow/agent-end' | 'workflow/end' -/** - * The workflow-seam error codes. Every one of these is FATAL when it reaches - * a script (see {@link WorkflowError.fatal}): the combinators re-throw it - * instead of dissolving it into an ordinary per-item `null`. - * - * - `SCRIPT_PARSE` — the script (or its meta statement) does not parse. - * - `META_INVALID` — the meta block evaluated but fails the shape contract. - * - `INVALID_ARGUMENT` — a hook was called with malformed arguments. - * - `UNSUPPORTED_OPTION` — an `agent()` option this engine does not support - * (deferred: `effort`/`isolation`/`agentType`) or does not know. - * - `UNSUPPORTED_SCHEMA` — an `agent()` schema outside the structured-output - * subset (see dsh-tools). - * - `AGENT_CAP` / `ITEM_CAP` — the run/agent caps tripped. - * - `AGENT_START` — the provider's asynchronous start rejected before - * cancellation took precedence. - * - `AGENT_RESULT` — a ready run had its `result` REJECT: an infrastructure - * fault at the subagent seam. This is distinct from a child that failed and resolved - * (which is the per-item `null`, never an error). - * - `RESULT_UNSERIALIZABLE` — a value crossing the script/host value boundary - * is not plain JSON data. - * - `CANCELLED` — the run was cancelled; pending and future hooks reject - * with this (the script-kill mechanism). - */ +/** Machine-routable fatal workflow failures. Child-run failures are not codes. */ export type WorkflowErrorCode = | 'SCRIPT_PARSE' | 'META_INVALID' @@ -182,31 +145,9 @@ export function isFatalWorkflowError(error: unknown): boolean { } /** - * Abstract workflow execution service. Subclass, implement {@link start}, and - * load the subclass as a plugin — it registers as `ctx.workflows` (one - * implementation per context; loading a second throws, cordis' standard - * duplicate-service behavior). - * - * Semantics every implementation must honor: - * - {@link start} throws synchronously for a request that cannot begin (an - * unparseable script, an invalid meta block). Once it returns a - * {@link WorkflowRun}, `result` NEVER rejects — every failure resolves with - * `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, - * `result` SETTLES within the implementation's bounded grace even if the - * script itself never settles (a consumer awaiting `result` must never be - * wedged past a cancellation). - * - The `workflow/*` events fire through {@link emitWorkflowEvent} (borrowed - * immutable data, per-listener containment); `workflow/end` fires exactly once - * per started run, after `result` is settled or as it settles. - * - `dispose()` reaches quiescence within a bounded grace: it cancels, waits - * for the script to settle AND its started children to finish disposing, - * and abandons whatever is left rather than hanging its caller (the engine - * documents what abandonment leaves behind). - * - Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to - * the `start()` caller and does not track its live runs — disposing the - * engine's own fiber mid-run deliberately leaves those runs to their - * holders' teardown, so an engine reload cannot yank a run out from under - * the consumer awaiting it. + * Workflow execution seam. Invalid requests throw before publication; a live + * run is holder-owned, its result never rejects, cancellation and disposal are + * bounded, and disposal waits for child cleanup within that bound. */ export abstract class WorkflowService extends Service { constructor(ctx: Context) { @@ -222,14 +163,7 @@ export abstract class WorkflowService extends Service { abstract start(request: WorkflowStartRequest): WorkflowRun /** - * Emit one `workflow/*` lifecycle event with per-listener containment. Each - * subscriber receives the same borrowed immutable payload; a throw or - * asynchronously rejected listener is logged (never propagated — the logging - * itself is total, even for a thrown value whose own string coercion - * throws), so one bad subscriber can neither fail the engine mid-run, - * surface as an unhandled rejection on a detached settle hook, nor starve - * the listeners registered after it (cordis `emit` halts on the first throw - * — same guarantee as the subagent seam's lifecycle emits). + * Emit a lifecycle event while containing and logging each listener failure. * @param name - the `workflow/*` event to dispatch. * @param args - the event's payload, matching its declared signature. */ @@ -248,10 +182,7 @@ export abstract class WorkflowService extends Service { } /** - * Total renderer for a listener-thrown value: the containment catch must never - * itself throw, and `String(error)` does when the value's own `toString` / - * `Symbol.toPrimitive` throws. Local rather than an engine package's renderer - * — the seam sits below every engine and cannot import one. + * Render any thrown value without violating listener containment. * @param error - any thrown value. * @returns `String(error)`, or a fixed label when even coercion throws. */ @@ -259,8 +190,7 @@ function renderListenerError(error: unknown): string { try { return String(error) } catch { - // Only a throwing toString/Symbol.toPrimitive lands here; the fixed label - // keeps the containment guarantee total. + // String coercion itself may throw. return '[unrenderable thrown value]' } } diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index a93ea98173..1ccdac84f6 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,12 +1,6 @@ /** - * Boot the Code Mode demo under the UI named on the command line: - * `pnpm run demo:code-mode [repl|acp]`, default `repl`. Code Mode is the - * point — the UI is just the surface it happens to wear: each UI boots its - * base example through that example's `code-mode.cordis.yml` overlay - * (include ./cordis.yml, flip `tools.mode` to `code`, insert the - * worker-thread code runtime). Both need DEEPSEEK_API_KEY (repo-root .env - * works). Anything else on the command line is a misconfiguration and - * fails loud with usage. + * Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Both require a + * DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 88bd243bc2..e38811c082 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -5,7 +5,7 @@ "docs/cordis-primer.md": 550, "docs/defensive-patterns.md": 550, "docs/testing.md": 800, - "examples/AGENTS.md": 462, - "packages/AGENTS.md": 460, + "examples/AGENTS.md": 200, + "packages/AGENTS.md": 290, "packages/README.md": 710 } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 70593c0fe1..b0c8a57758 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -785,7 +785,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).', + 'Filesystem read-before-edit policy stays on `fs/*` events. Generic pre/post waterfalls host hook and approval policy, `ctx.approval` resolves asks before guards, and `tools/execute` hosts around-dispatch concerns such as timeouts. `tools/result` observes the immutable final outcome. Code Mode sends both `run_code` and its serialized sub-calls through this pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index c1985d3ff8..e614ddd90a 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -81,15 +81,7 @@ function rawJsDoc(text: string, node: ts.Node): string { return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' } -/** - * Parse a raw JSDoc block into description prose, flagging whether any `@mode` - * tag is present (forbidden on log events). Output obeys the repo's markdown - * conventions so the generated file passes verify-md-wrap: each prose paragraph - * collapses to ONE physical line, and a `-` bullet list is preserved with each - * item on its own single line (continuation lines folded in). `{@link Foo}` - * unwraps to `Foo`. Description prose ends at the FIRST block tag (standard - * JSDoc semantics): tag lines and their continuation lines are never prose. - */ +/** Parse pre-tag JSDoc prose into one-line paragraphs and bullets for the catalog. */ function parseJsDoc(raw: string): { doc: string; hasMode: boolean } { const inner = raw .replace(/^\/\*\*/, '') diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 57def96573..83b8509e39 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -73,12 +73,8 @@ function unwrapExpression(e: ts.Expression): ts.Expression { } /** - * Classify a declarator's type annotation for the function contract: an - * inline function type or a type literal that is EXACTLY one call signature - * is the surface signature itself; a literal mixing call/construct - * signatures with anything else cannot be classified syntactically and is - * refused (fail closed — extract a named type); everything else is a plain - * value shape. + * Classify inline callable annotations. Mixed callable literals fail closed; + * other annotations are ordinary value shapes. * @param type - the declarator's type annotation. * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape. */ diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 3f9af495b3..2f9331e6d6 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -1,15 +1,6 @@ /** - * Doc-sync gate: verify every fenced ```mermaid block parses with Mermaid's - * own parser. Markdown link/type/code gates can say a diagram block exists and - * is linked, but only Mermaid can catch syntax errors that GitHub would fail to - * render. - * - * Scope matches the Markdown link gate so any Mermaid diagram in repo-authored - * docs is checked: README.md, README.zh.md, docs/** /*.md, - * packages/* /*.md, packages/* /* /*.md, examples/** /*.md, AGENTS.md, - * packages/AGENTS.md, and .agents/skills/** /*.md. - * - * Run: `tsx scripts/verify-mermaid.ts`. + * Parse every repo-authored Mermaid fence with Mermaid itself. Scope matches the + * Markdown link gate. Run with `tsx scripts/verify-mermaid.ts`. */ import { globSync, readFileSync, realpathSync } from 'node:fs'