diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index b4a43f124e..e0843351e2 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -42,7 +42,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Module comments:** state the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation. - **Tests:** explain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories. - **Cookbooks:** include prerequisites, required actions, the real entry path, observable verification, and concise warnings. -- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Link algorithms and design rationale. +- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Quote stable model-visible text owned by the package; link generated catalogs and cross-package owners. Keep durable gaps and maintainer traps, not ordinary cleanup inventories. Follow the [package README contract](../../../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - **RFCs:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps. Implemented RFCs state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. diff --git a/.agents/skills/dsh-prose-standard/references/examples.md b/.agents/skills/dsh-prose-standard/references/examples.md index f267d288e5..45ca4bbf5c 100644 --- a/.agents/skills/dsh-prose-standard/references/examples.md +++ b/.agents/skills/dsh-prose-standard/references/examples.md @@ -135,3 +135,23 @@ Keep the consequence of order, a surprising scope rule, or a security boundary. **Shorter but worse:** “The adapter normalizes provider errors.” **Balanced decision:** Keep the current sentence unless a link or surrounding contract already carries the failure categories. The shorter version loses the consequence and distinctions without improving structure. + +## Model-visible text follows ownership + +**Over-trimmed:** “The tool returns errors when a call fails.” + +**Over-detailed:** Copying another package's schema and renderer strings into this backend's README. + +**Balanced:** Quote stable prompt, result, and error text owned by this package. Link the generated tool catalog for schemas and the consumer README for text another package owns; state only this package's conditions or deltas locally. + +Wording that reaches a model is behavior, but duplication still drifts. Exactness belongs at the owner. + +## Limitations are contracts, not debt inventories + +**Over-trimmed:** Omitting a process-lifetime cache that makes configuration changes require plugin reload. + +**Over-detailed:** Listing private helper cleanup and unused test-only accessors with no caller or maintainer consequence. + +**Balanced:** “Provider selection is cached for the plugin lifetime; installing or repairing a provider requires reload.” Keep ordinary cleanup in its TODO or RFC. + +Retain gaps and non-obvious constraints that affect use or safe maintenance. A package README is not a backlog dump. diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bc7db7eeed..c38d049e2e 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,12 +15,12 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | [rfc/](rfc/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` RFCs describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | -| Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | +| Package README | The per-package contract: config, semantics, limitations, extension points, and [Model Experience](cookbook/adding-a-package.md#4-write-the-package-readme) | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | | [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts | | Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind | | Skills (`.agents/skills/`) | Workflows: how to carry out a recurring task against the contracts | The contracts themselves (→ docs) | -Placement test: bug story → postmortem; design rationale → RFC; procedure → cookbook; type shape → core-data-structures; package contract → README; universal rule → root AGENTS.md with a link to its rationale. +Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; type shapes → core data; package contracts → READMEs; standing orders → root `AGENTS.md` with a rationale link. ## Writing rules @@ -28,7 +28,6 @@ Placement test: bug story → postmortem; design rationale → RFC; procedure - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). -- **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2a3d83378a..0f7436c1e6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -267,7 +267,7 @@ Requires: `bash` export interface Config { /** * Path to a `hooks.json` or a settings file whose `hooks` key holds the config. - * PROCESS-LEVEL: read once at load, a relative path resolves against the process + * Process-level: read once at load, a relative path resolves against the process * launch cwd, so one config applies to the whole process. * TODO(per-session-hook-config): per-session discovery of a project-local * `hooks.json` from each `session/new.cwd` is not yet implemented. @@ -292,7 +292,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:41`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:43`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -302,7 +302,7 @@ Requires: `bash` /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** - * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative + * Path to a Codex `hooks.json`. Process-level: read once at load, a relative * path resolves against the process launch cwd. * TODO(per-session-hook-config): per-session project-local discovery from each * `session/new.cwd` is not yet implemented. @@ -317,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:38`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:41`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-jsonrpc` diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 852d403e2b..db0684b762 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -13,7 +13,10 @@ packages/// # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts - README.md # service API, events, extension points, design notes + README.md # service API, events, extension points, design notes, + # + gated Model Experience context blocks or short sentence + # + the gated "Known Limitations and Deferred Work" section + # (or a whitelist entry in scripts/verify-package-readme-limitations.ts) ``` Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. @@ -37,10 +40,39 @@ Covered automatically by globs or package-manifest discovery — no edits needed For a swappable capability, split interface / implementation / consumer into separate packages (see docs/architecture.md § "Capability seams" — the bash trio is the template). A single-purpose plugin stays one package. -## 4. Verify +## 4. Write the package README + +Keep package-specific service API, config, events, extension points, and design notes first. The limitations section records durable consumer gaps and non-obvious maintainer constraints owned by this package; ordinary cleanup stays in its source TODO or RFC. An indirect Model Experience sentence may name the consumer that surfaces this package's contribution, but it does not restate that consumer's implementation. End a package README with this canonical sequence: + +````markdown +## Model Experience + +### Request surface and condition + +**What the model sees**: An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below. + +**Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect. + +#### Verbatim text for this context surface, when needed + +```markdown +Stable system-prompt prose of any length, or another long non-generated literal, copied exactly from source. +``` + +## Known Limitations and Deferred Work + +- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint. +```` + +Fill Model Experience from the implementation. Use one H3 per direct, conditional, capped, lifetime, or auxiliary-model surface, with the two fields shown above. Quote stable text owned by the package: system-prompt prose goes in a titled H4 plus `markdown` fence, other short literals stay inline with named placeholders, and other long literals use the same nested form. Summarize only data-dependent or provider-owned text. A tool-schema surface links its anchored section in the generated [tool catalog](../tool-catalog.md) and states only deltas absent there. Keep prompt and schema surfaces separate when scoping can hide one without the other. The [prose standard](../../.agents/skills/dsh-prose-standard/SKILL.md) governs completeness and ownership; the verifier enforces the mechanical shape. + +A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts); a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. + +## 5. Verify ```sh pnpm install # registers the workspace +pnpm run doc-sync pnpm run constraints && pnpm run typecheck && pnpm run lint pnpm run test:coverage # 100% per-file over src (types.ts exempt) pnpm run build && pnpm run hygiene diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index afeb2e2906..5f0d2fe963 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -95,7 +95,7 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com Types: [Message](../core-data-structures/core.md) -Source: [`packages/compact/compact/src/index.ts:34`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:36`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) @@ -154,7 +154,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:114`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 3bf437c2ed..d0161372d7 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -154,7 +154,7 @@ interface CollectedOutput { ## File sandbox: `BashSandboxInfo` -A sandbox-consuming executor (`dsh-bash-sandbox`) exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each agent session's durable `bash/sandbox-mode` override, stamps the effective mode onto the request, states it in the per-agent prompt, and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned and cataloged by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md), whose provider wraps the executor's argv; modes govern FILE effects only, not network or process visibility. +A sandbox-consuming executor (`dsh-bash-sandbox`) exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each agent session's durable `bash/sandbox-mode` override, stamps the effective mode onto the request, and may replace it for one user-approved strictly wider call. It deliberately neither states the standing mode nor narrates switches; a denial result names the mode that command actually ran under. The mode/enforcement vocabulary is owned and cataloged by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md), whose provider wraps the executor's argv; modes govern FILE effects only, not network or process visibility. A sandboxed run always reports the facts it executed under on `BashRunResult.sandbox`: `denied` is the executor's conservative classification of a failure as sandbox-caused (a failed exit whose stderr carries a filesystem-permission signature — never a clean exit or a signal kill), read from the collected stderr tail; `enforcement` reports how completely the selected backend governs the mode's file effects (`SandboxEnforcement = 'full' | 'partial'` — `partial` when an older Landlock ABI governs only a subset of the requested accesses; absent under `danger-full-access`, where nothing is confined); `runnerFailed` marks the opposite of a denial — the sandbox RUNNER itself failed and the command never ran (stamped only on settled background tasks; a foreground run surfaces the same condition as the thrown `SANDBOX_UNAVAILABLE` error): @@ -193,7 +193,7 @@ interface BashSandboxInfo { } ``` -One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend. A selected runner refusing its profile reaches the same fail-closed foreground error; a settled background task records `runnerFailed`. The model sees the current effective mode in the prompt, receives denial/runner facts in results, and can request a one-shot strictly wider retry through `sandbox_permissions` plus `justification`; `ctx.approval` must grant that exact call before anything executes. The complete policy and switching design is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md). +One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (owned by the [sandbox seam](sandbox.md)) is what the `ctx.sandbox` provider throws — and the executor propagates — when a confined mode has no usable backend. A selected runner refusing its profile reaches the same fail-closed foreground error; a settled background task records `runnerFailed`. The model receives denial/runner facts in results, learns the effective mode only when a denial marker names it, and can request a one-shot strictly wider retry through `sandbox_permissions` plus `justification`; `ctx.approval` must grant that exact call before anything executes. The complete policy and switching design is the [sandbox RFC](../rfc/implemented/feature/2026-07-06-sandbox.md). ## Background tasks: `BashTask` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 234a489532..a27b19b9a7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -75,13 +75,13 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/ #### `bash/sandbox-mode` — log-only -The session's sandbox mode was switched — log-only (like `approval/*`; NOT a surface event, carries no `surfaceOp`): durable and replayable, never in the model transcript. The LAST such event is the session's override (effectiveSandboxMode); who asked for it is derivable from position (an event after the log's last `request/header*` was a runtime switch by the user; see the tool layer's narrator). +Durable log-only sandbox-mode override; never a surface event or model message. Execution and ACP option reporting fold the latest event through effectiveSandboxMode without adding a prompt notice. ```ts persistence-catalog 'bash/sandbox-mode': { mode: SandboxMode } ``` -Source: [`packages/bash/bash/src/session-mode.ts:22`](../packages/bash/bash/src/session-mode.ts) +Source: [`packages/bash/bash/src/session-mode.ts:20`](../packages/bash/bash/src/session-mode.ts) ### `compact/*` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 54e59f7aa1..c99226a264 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -171,6 +171,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Raise the Node LTS engine floor to 22.19](implemented/process/2026-07-06-node-engine-floor.md) | 2026-07-06 | | [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 | | [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 | +| [A gated Known-Limitations section in every package README](implemented/process/2026-07-10-readme-known-limitations-gate.md) | 2026-07-10 | +| [Package Model Experience contract](implemented/process/2026-07-12-package-model-experience-contract.md) | 2026-07-12 | ### Testing 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 16c8f6dab7..2d285fb152 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -6,14 +6,14 @@ Status: implemented The harness's extension surface is its typed interception seams ([the interception-seams RFC](2026-06-30-interception-seams.md)): a "native hook" is just an ordinary cordis plugin subscribing to `agent/session-start`, `agent/prompt-submit`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`, `subagent/start`, `subagent/end`. But users arrive with **existing** Claude Code (CC) and Codex hook configs — a `hooks.json` (or a settings file's `hooks` key) full of shell-command hooks — and want those to run unmodified. This RFC introduces the two **bridge plugins** that translate that external shell-hook protocol onto the typed seams, built on the shared wire-protocol library ([the hook-protocol-lib RFC](2026-06-30-hook-protocol-lib.md)). -The framing that shapes the whole design: **a bridge is a faithfulness adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's only reason to exist is to run an UNMODIFIED external CC/Codex hook with byte-faithful semantics. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, map the neutral outcome onto a seam Decision. +The framing that shapes the whole design: **a bridge is a compatibility adapter, not a power tool.** Anything a bridge does (block a tool, inject context, force continuation, observe a subagent) a native cordis plugin does more powerfully — typed returns, full `ctx`, no serialization boundary. The bridge's reason to exist is to run the explicitly supported subset of external CC/Codex command hooks. That keeps each bridge thin: parse the config, pick a matcher mode, build the per-event payload, call `runHook` + `mergeHookOutputs` from the shared lib, and map the neutral outcome onto a seam Decision. The package READMEs own the exact current unsupported-event and partial-field inventory against the official protocols. ## Decision Two independent plugins in the `packages/hooks/` group, each a function/namespace plugin (`name`/`inject`/`Config`/`apply`, NO default export — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)) injecting only `bash`: -- **`dsh-hooks-claude`** — the CC dialect. Seven hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. Owns CC's per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**. -- **`dsh-hooks-codex`** — the Codex dialect: a deliberate SUBSET. Five hook points (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent/notification/compaction), an always-regex matcher, snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no env and no `${…}` substitution, and a block-only decision model (a Codex hook can never pre-approve, so `allow`/`ask` are not honored). A tool call's payload carries the real `tool_name` (the value the matcher tests, so a config's tool matcher fires) in Codex's `tool_input: { command }` shape. +- **`dsh-hooks-claude`** — the CC dialect. Seven of Claude Code's current hook points: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, and `SubagentStop`. Owns CC-shaped per-event stdin payloads (a base of `session_id`/`cwd`/`hook_event_name` plus per-event fields), `CLAUDE_PROJECT_DIR` plus `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the literal-or-regex matcher mode. A CC hook's stdin carries a **trailing newline**. +- **`dsh-hooks-codex`** — five of Codex's current hook points: `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. It uses an always-regex matcher, Codex-shaped snake_case payloads with `turn_id`/`model`/`permission_mode` extras written WITHOUT a trailing newline, no Codex plugin-env injection or config-time placeholder substitution, and no pre-tool approval or rewrite path. A tool call's payload carries the real `tool_name` in the bridge's reduced `tool_input: { command }` shape. ### Outcome → Decision mapping @@ -26,14 +26,14 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se | `tools/pre-execute` | `deny`→`deny`; `ask`→`ask` | `block`→`deny` (no allow/ask) | | `tools/post-execute` | `deny`→`block`+feedback; context-only→delegate+fold | same | | `agent/turn-continuation` | blocking Stop → `continue` (reason = next-step steering) | same | -| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | — (not a Codex event) | -| `subagent/end` (emit) | observe-only | — | +| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | unsupported by this bridge | +| `subagent/end` (emit) | observe-only | unsupported by this bridge | The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). A composed ACP answerer prompts the owning editor session and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`. ### Context source is always the plugin (the mislabel guard) -`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }` — which would record plugin-injected context as if the user had typed it. So every bridge `inject()` and every `HookContext` passes an explicit `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }` source. A test asserts the resulting `context/message.source` is the plugin, never `user`. +`agent.inject()` defaults a missing `MessageSource` to `{ kind: 'user' }`, so every bridge `inject()` and `HookContext` passes `{ kind: 'plugin', plugin: 'hooks-claude' | 'hooks-codex' }`. Unit coverage pins the resulting `context/message.source` as the plugin rather than the user. ### Adding context is not a veto — delegate, then fold @@ -45,16 +45,16 @@ Claude Code always exports `CLAUDE_PROJECT_DIR`, and common unmodified hooks ref ### Containment -The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run — a `prompt`/`agent`/HTTP hook (CC) or an `async: true` / non-command hook (Codex) is parsed-and-skipped with a warning. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop). +The config is parsed ONCE at load; a read/parse failure logs and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run for CC; `http`, `mcp_tool`, `prompt`, and `agent` handlers are parsed-and-skipped. Codex runs only synchronous command handlers and skips `async: true` or non-command entries. The emit-listener paths (`session-start`, `subagent/start`) run detached, with their `inject` contained in a `.catch` that logs (a throwing inject must not break session boot or the loop). ### Where hooks run, and where their config comes from 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) +## Deferred compatibility gaps - **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field. -- **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. +- **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking 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 (`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. diff --git a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 5b6eeb4ccb..3c2e17aebd 100644 --- a/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/docs/rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -32,7 +32,7 @@ This uses the normal system-prompt registration mechanism rather than a second p ### Tool filtering is one live global-view rule -The tool filter controls visibility and executable lookup together. An in-process provider installs `ToolRegistry.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to prompt schemas, lookup, execution, and Code Mode SDK generation. +The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRegistry.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and Code Mode SDK generation. Independently registered system-prompt sections are outside `ToolRegistry`, so filtering a tool does not remove that plugin's standalone guidance. Resolution follows these rules: diff --git a/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md new file mode 100644 index 0000000000..6cd8851149 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md @@ -0,0 +1,27 @@ +# RFC: A gated Known-Limitations section in every package README + +Status: implemented + +## Problem + +The [documentation standard](../../../AGENTS.md) assigns limitations to package READMEs. Without a shared shape, an omitted section cannot distinguish an audited absence from forgotten documentation, and variant headings prevent a repository-wide search. + +## Decision + +Every package manifest under `packages///package.json` has a sibling README with the canonical `## Known Limitations and Deferred Work` section. Its bullets record durable consumer gaps and non-obvious maintainer constraints owned by that package; ordinary cleanup remains in its source TODO or owning RFC. The [`verify-package-readme-limitations` gate](../../../../scripts/verify-package-readme-limitations.ts) derives the package set from manifests, rejects missing READMEs, and requires exactly one canonical h2 with at least one top-level bullet. Near-miss headings such as “Limitations,” “Deferred,” “What is NOT here,” or “Non-goals” fail. + +A package with nothing to declare is listed in `NO_LIMITATIONS` and omits the section. Adding a limitation requires removing the entry; renames and removals fail because every entry must name a scanned package. + +The gate checks presence, shape, and the allowlist. Review under the documentation and [prose](../../../../.agents/skills/dsh-prose-standard/SKILL.md) standards owns coverage and accuracy. The standing rule lives in [packages/AGENTS.md](../../../../packages/AGENTS.md). + +## Alternatives considered + +- **Free-form headings** — cannot be searched uniformly and still need near-miss detection. +- **Require an empty section or “None.”** — boilerplate can remain after a package gains a limitation; an allowlist makes absence explicit and reviewable. +- **Impose a word ceiling** — legitimate limitation counts vary, so review governs this unbudgeted README tier. + +## Consequences + +- New packages declare qualifying limitations or explicitly join the allowlist; missing, drifted, and empty sections fail `doc-sync` locally and in CI. +- The gate adds one dependency-free TypeScript script to `doc-sync`. +- Renaming the enforced heading requires changing the script and every package README together. diff --git a/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md new file mode 100644 index 0000000000..0f9b0b02a0 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md @@ -0,0 +1,31 @@ +# RFC: Package Model Experience contract + +Status: implemented + +## Problem + +A package README can explain APIs and runtime mechanics without answering the question that dominates an agent harness's behavior and cost: what from this package reaches a model request, under which conditions, and how long those tokens remain. The omission is especially hard to audit in a plugin architecture. A consumer may turn a backend result into a tool message, a policy plugin may replace success with an error, compaction may remove old history, and an agent-scoped registration may change one agent's prompt or schemas while leaving every other agent unchanged. Reading only the nominally model-facing packages therefore misses real context effects, while reading source across every dependency is too expensive for routine review. + +## Decision + +Every workspace package README with a model-facing or model-adjacent contract ends with the canonical [Model Experience section](../../../cookbook/adding-a-package.md#4-write-the-package-readme), immediately before `## Known Limitations and Deferred Work`; a package on the no-limitations allowlist ends with Model Experience itself. An audited model-agnostic generic package omits the section through `NO_MODEL_EXPERIENCE_SECTION`. + +Packages with direct, conditional, capped, lifetime, multi-surface, or auxiliary-model effects use one H3 per context surface. Each names what the relevant model receives and when, then classifies the token effect. Stable package-owned text is quoted exactly: system-prompt prose and other long literals use a nested H4 plus `markdown` fence, while short literals stay inline with named interpolation placeholders. Tool-schema surfaces link their anchored section in the generated [tool catalog](../../../tool-catalog.md) and state only composition or configuration deltas; runtime-only definitions explain why the catalog omits them. Data-dependent and provider-owned text is summarized. Agent-scoped visibility is explicit, and prompt and schema surfaces remain separate when scoping can hide one without the other. + +A package with no model-context effect, or one path rendered entirely by another package, uses the verifier's audited one-sentence form: `None, as ` or `Indirectly, through `. Pure transport and keyless test-support packages use the none form when they create no model-bound content. Provider backends use the indirect form even when they cap or filter data, and wiring bundles use it when named children own every effect. These sentences locate the contribution without restating the consumer. Structured sections likewise document only package-owned inputs, transformations, and deltas. + +`verify-package-readme-model-experience` discovers package manifests and validates the three classifications, canonical final-section order, required fields, concrete literal evidence, nested verbatim blocks, and anchored tool-catalog links. It runs in `doc-sync` and the parallel gate runner. Review still owns coverage, link relevance, and factual accuracy. + +## Alternatives considered + +- **Document only packages that register prompts or tools** — rejected because backends, policy plugins, adapters, persistence, scoping, and compaction change the content or lifetime of tokens without owning a model-facing schema. +- **Generate one central context-cost catalog from source** — rejected because an AST can find registrations but cannot infer semantic conditions such as history retention, output truncation, parent-versus-child visibility, or an auxiliary model boundary. The package README is the implementation-local contract; a central copy would add another drift surface. +- **Require numeric token counts** — rejected because exact counts depend on the selected model tokenizer, adapter serialization, configuration, and runtime data. The stable contract is the growth shape: fixed per request, conditional per call, retained, replaced, capped, or zero-direct. +- **Use a three-column table** — rejected because exact source text and conditional result shapes make cells dense and difficult to scan. Repeated subsections give each context surface readable vertical space while preserving the same fields. +- **Allow every zero-impact package to omit the section** — rejected because unconstrained absence is ambiguous between an audited zero and forgotten documentation. Omission is reserved for model-agnostic generic packages named with a reason in the verifier; model-adjacent zero-impact packages keep one explicit sentence. +- **Require the full structured form for audited zero or simple indirect packages** — rejected because it repeats labels around one fact. A gated sentence preserves explicit coverage without the ceremony. +- **Convention without a gate** — rejected because a repo-wide contract must also cover every future package; review memory cannot reliably detect an omitted README section. + +## Consequences + +A reviewer can start at any model-facing or model-adjacent package and see its contribution to the conversation model, child models, and auxiliary calls without reconstructing the full plugin graph. Token-budget work can distinguish repeated request overhead from data-dependent history, and agent-scoped changes have an explicit documentation checkpoint. Package authors maintain one or more compact context-surface blocks or one classified sentence whenever model-visible behavior changes; audited generic packages carry no irrelevant model boilerplate. The structured fields do not promise provider-exact token counts; measurements remain model- and workload-specific, while the documented growth and visibility contract stays stable. diff --git a/package.json b/package.json index ee5b9f6e35..2d7a269f8d 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,14 @@ "verify-md-links": "tsx scripts/verify-md-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", + "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-rfc-format": "tsx scripts/verify-rfc-format.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", + "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", @@ -65,7 +67,7 @@ "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 3908ad5550..1f92a850a9 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Harness Packages -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. +These package-specific rules supplement the repo-wide [conventions](../AGENTS.md#conventions). - **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)). @@ -12,6 +12,6 @@ Naming notes: - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. -- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)). - -Read the per-package README.md for package-specific details: service API, events, extension points, TODOs. +- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. +- Package READMEs document model/token effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). +- Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or RFC. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/README.md b/packages/README.md index d3dd6818df..13da4b50da 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,4 +38,4 @@ The inter-package dependency graph is generated: [docs/module-graph.md](../docs/ The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). -Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). +Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index ceaf6ff699..73236b16b3 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -25,6 +25,17 @@ Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; - **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 +## Model Experience -Execution policy does NOT belong in this package: this executor always runs commands unconfined. Confinement is [`dsh-bash-sandbox`](../bash-sandbox/README.md), which extends this executor verbatim and confines commands under the `ctx.sandbox` seam's bwrap/Landlock/Seatbelt backends ([sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); per-call allow/deny/ask policy belongs on the `tools/pre-execute` gate. +Indirectly, through `dsh-tool-bash`, which renders this executor's bounded stdout/stderr tails, background-task deltas and state, spill-file path, exact `Error: unknown bash task ""` and `Error: aborted before spawn: ` failures, and retains each resulting tool message until compaction. + +## Known Limitations and Deferred Work + +- **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. +- **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. +- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. +- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. +- **Spill files are never deleted** — full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them. +- **Finished background tasks are never evicted** — they stay in the task map, retaining their in-memory output tails, until executor disposal. + +The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index d00286d52f..0d0ebca439 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-sandbox -Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — the model-facing tool layer (`dsh-tool-bash`) is untouched; that swap is exactly what the seams exist for. +Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields. Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only. @@ -31,3 +31,30 @@ Deny-only at the seam: a denial is a reported fact, and this executor never nego ``` The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landlock.e2e.ts`, and `tests/seatbelt.e2e.ts` (the real provider + real runner driven through `ctx.bash`, world-verified, each self-skipping where its runner is absent); see [the acp-agent example's default composition](../../../examples/acp-agent/) for the runnable demo. + +## Model Experience + +### Bash tool schema, indirectly + +**What the model sees**: The generated [`dsh-tool-bash` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash) are the baseline. By advertising a confining `sandboxMode`, this backend augments `bash` with `sandbox_permissions` using enum `workspace-write` | `danger-full-access` and with `justification`. The backend adds no prompt prose, and the session's effective mode remains unstated. + +**Token effect**: Small fixed schema increment on requests where `bash` is visible; mode switches add no context tokens. + +### Bash tool result, indirectly + +**What the model sees**: After ordinary bounded output, a denied call appends exactly `[sandbox: file access denied under mode]`. When escalation is available it next appends `[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]`. A settled background runner failure instead appends `[sandbox: the sandbox runner itself failed under mode — the command did not run; this is a sandbox problem, not a command failure]`. + +**Token effect**: Zero additional tokens on an unremarkable allowed run beyond ordinary output. Denial or failure adds the quoted conditional marker, retained until compaction. + +### Bash tool error, indirectly + +**What the model sees**: If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail. + +**Token effect**: Conditional error text is visible for that call and retained in history until compaction. + +## Known Limitations and Deferred Work + +- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox. +- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed. +- **A background runner failure has no immediate error channel** — it is recorded on the settled task and surfaces when the caller polls with `bash_output`. +- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile. diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 948bf5cccf..3b8837a7b6 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -82,9 +82,11 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r } /** - * Registers as `ctx.bash` in place of the local executor and consumes a - * `ctx.sandbox` provider. Its configured mode is the fallback; each resolved - * call may carry a session override or approved one-shot escalation. + * Registers as `ctx.bash` in place of the local executor and requires a + * `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is + * the fallback, while a session override or approved one-shot escalation may + * select each call's mode. The prompt does not state the standing mode; + * `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { static inject = ['sandbox'] @@ -148,9 +150,8 @@ export class SandboxBashExecutor extends LocalBashExecutor { } const confined = this.confine(spec.command, mode) const result = await super.run({ ...spec, command: confined.command }) - // Runner failure outranks denial: the sandbox itself failed and the command never RAN — - // surface the same structured fail-closed error a confine-time discovery throws (late - // detection, same outcome), with the runner's own first stderr line as the cause. + // Runner failure outranks denial because the command did not run. Throw the + // same fail-closed error as confine-time discovery with the first stderr line. if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) { throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0]) } diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 48f8de73a3..540eef700c 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -11,7 +11,7 @@ This package is the interface quarter of the bash capability, split so each conc | `@deepseek-ai/dsh-bash-sandbox` | an implementation: `dsh-bash-local`'s mechanics with every spawn confined via [`ctx.sandbox`](../../sandbox/sandbox/), denials reported as result facts | | `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` | -The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface, tool schemas untouched; a containerized or remote executor slots in the same way. +The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. `dsh-bash-sandbox` is exactly that swap in action — a sandboxing executor behind the same interface; the consumer detects its `sandboxMode` capability and adds escalation fields without importing the implementation. A containerized or remote executor slots in the same way. ## Service API (`ctx.bash`) @@ -35,3 +35,12 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal The seam owns per-session sandbox overrides through the log-only `bash/sandbox-mode` event, `effectiveSandboxMode`, and `setSandboxMode`; writers preserve turn enclosure, and replay restores the last override. `BashTaskId` and `OwnerToken` are distinct brands. Foreground `run` returns exit, timeout, cancellation, output, and optional sandbox facts; background `start` and `readOutput` use task records. A sandboxing executor reports the executed mode, conservative denial classification, and enforcement completeness. See [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md) for full shapes. `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 — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). + +## Model Experience + +Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. + +## Known Limitations and Deferred Work + +- **No interactive-input vocabulary** — `stdin` is written once at spawn and closed; the seam has no channel to feed a running task and no PTY session concept. +- **Foreground timeouts are always executor-owned** — a caller-owned-deadline mode on the seam is explicitly deferred by [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). diff --git a/packages/bash/bash/src/session-mode.ts b/packages/bash/bash/src/session-mode.ts index 2ffcf2d273..dfe8c22f4f 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/bash/bash/src/session-mode.ts @@ -1,8 +1,9 @@ /** * Per-session sandbox-mode override stored as log-only events. Folding the log - * isolates sessions and survives replay; the tool stamps the result onto each - * call unless a one-shot escalation grant overrides it. The model sees the - * effective mode through prompt guidance and boundary notices, not the event. + * isolates sessions and survives replay; the tool stamps the override onto + * each call unless an approved one-shot escalation outranks it, and the + * executor default applies when neither exists. The model receives neither the + * event nor a standing-mode notice; denial results name the effective mode. * @module dsh-bash/session-mode */ @@ -12,12 +13,9 @@ import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** - * The session's sandbox mode was switched — log-only (like `approval/*`; - * NOT a surface event, carries no `surfaceOp`): durable and replayable, - * never in the model transcript. The LAST such event is the session's - * override ({@link effectiveSandboxMode}); who asked for it is derivable - * from position (an event after the log's last `request/header*` was a - * runtime switch by the user; see the tool layer's narrator). + * Durable log-only sandbox-mode override; never a surface event or model + * message. Execution and ACP option reporting fold the latest event through + * {@link effectiveSandboxMode} without adding a prompt notice. */ 'bash/sandbox-mode': { mode: SandboxMode } } @@ -28,9 +26,8 @@ export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-wr /** * The session's sandbox-mode override: the last `bash/sandbox-mode` event in - * the log, or undefined when the session never switched (callers apply the - * executor's configured default). The pure fold — resume needs no catch-up - * machinery because replaying the log IS the state. + * the log, or undefined when the session never switched and callers should use + * the executor default. Replay needs no separate catch-up state. * @param events - session events in log order (other event types are skipped). * @returns the mode of the last switch event, or undefined without one. */ @@ -43,10 +40,9 @@ export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMo } /** - * THE write path for a session's sandbox-mode override: appends exactly one - * `bash/sandbox-mode` event — the switch IS its event; nothing mutates mode - * state out of band. Takes effect on the session's next bash call and next - * prompt assembly (the consumers fold on every read). + * Append one `bash/sandbox-mode` event as the only override write path. + * Execution and ACP option reporting fold it on read; prompt assembly does not + * consume it. * @param session - the session the override belongs to. * @param mode - the mode every subsequent bash call in this session runs * under (until the next switch). diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 9540be1d1a..3979a3db4b 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-tool-bash -The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees. +The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). This package owns schema and text shaping while process concerns stay behind the seam. Executor facts can change rendered results, and a sandboxing executor activates the escalation fields, without moving those presentation rules into the backend. Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). -The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Sandbox mode is intentionally learned from denial results, not announced in the prompt; see [Per-session mode](#per-session-mode-switching). +The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching). ## Tools @@ -38,7 +38,7 @@ The executor stores the spawning session id as the task's owner. `bash_output` a ## UI presentation -UI presentation is tool-owned through `presentCall` and `presentResult`. Foreground `bash` uses a terminal card whose title is the command, optional description is separate, and cwd follows `workdir` or the session; its result carries raw output and exit or signal data. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe, and malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics. +UI presentation is tool-owned through `presentCall` and `presentResult`. Foreground `bash` uses a terminal card whose title is the exact command and whose optional description is separate; cwd follows an explicit `workdir`—resolved by the bridge against the session when relative—or the session cwd. Its result carries raw output plus exit or signal data, and clients without terminal support receive a bridge-derived fenced console fallback. Background runs, spawn failures, `bash_output`, and `bash_kill` use generic cards. Presenters are pure and replay-safe; malformed older arguments fall back to generic rendering. See [`dsh-tools`](../../core/tools/) and [`dsh-acp`](../../ui/acp/) for card semantics. ## Background completion notices @@ -56,4 +56,49 @@ Escalating bash calls resolve `ctx.approval` before execution. `allowed-once` ap ## Per-session mode switching -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). +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. Neither the prompt nor a switch notice announces the standing mode; denial results report the effective mode when the boundary matters. See the [`dsh-bash` fold](../bash/README.md) and [sandbox switching contract](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). + +## Model Experience + +### System prompt + +**What the model sees**: Every request in this plugin's registration scope contains the bash guidance below. A sandboxing executor adds no mode statement or switch notice. Scoped tool restrictions can hide the schemas without removing this independently registered section. + +**Token effect**: Small fixed input cost per request while the plugin is active, unchanged by sandbox mode or mode switches. + +#### Bash guidance + +```markdown +Check the [exit code: N] marker on every bash result; investigate failures before moving on. +``` + +### Tool schemas + +**What the model sees**: The model sees the generated [`bash`, `bash_output`, and `bash_kill` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash). `sandbox_permissions` and `justification` augment `bash` only when the mounted executor advertises sandboxing. Agent-scoped tool restrictions can remove the definitions for that agent. + +**Token effect**: Fixed schema cost on every request where the tools are visible; sandbox support adds the escalation fields and its conditional description paragraph. + +### Foreground result + +**What the model sees**: The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. With no output it emits exactly `(no output)`. Conditional lines are exactly `[output truncated; full output: ]`, `[sandbox: file access denied under mode]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`; the sandbox escalation and runner-failure lines are quoted in [`dsh-bash-sandbox`](../bash-sandbox/README.md). + +**Token effect**: Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction. + +### Background task context and results + +**What the model sees**: Start returns exactly `started background task `. Completion injects exactly `background bash task finished . Read its output with bash_output.` Reads return only the data-dependent delta or `(no new output)`, optionally `[some output was dropped from memory; full output: ]`, then exactly one of `[status: running]`, `[status: killed]`, `[status: killed by ]`, or `[status: completed, exit code: ]`. Kill returns `killed background task ` or `task had already finished`. + +**Token effect**: Start and status text is small; deltas are data-dependent. The completion notice and every tool result are retained until compaction, but polling does not repeat already-delivered output. + +### Tool errors + +**What the model sees**: Validation and policy failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `invalid escalation: sandbox_permissions requires a justification`, `invalid escalation: justification is only valid together with sandbox_permissions`, `invalid justification: expected a non-empty sentence`, `invalid task_id: expected a string, got `, `task belongs to another session`, `sandbox_permissions is not available in this composition (no sandboxing executor to escalate)`, `sandbox escalation to "" is not strictly wider than this call's current "" mode`, the approval-availability/rejection/cancellation variants, and `command aborted`. + +**Token effect**: Only the failing call adds these retained tokens; a rejected escalation does not add command output because the command does not run. + +## Known Limitations and Deferred Work + +- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual. +- **The bash tools opt out of `timeout-policy` budgets** — `bash` keeps the executor-owned `BASH_TIMEOUT` path and `bash_output`/`bash_kill` declare no budget, per [the tool-call timeout-policy RFC](../../../docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). +- **Completion notices do not wake an idle agent** — they become durable context for the next request; a caller needing progress now must poll `bash_output` or send another message. +- **Tasks started outside an agent have no ownership fence** — their predictable ids are readable and killable by any caller; only agent-started tasks carry a session owner token. diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 3b83607b13..f66838bdde 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -27,14 +27,8 @@ export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] /** - * Validate the constraints the SchemaSpec can't express. `defineTool` - * validates parsed args against the SchemaSpec before `execute` runs (the - * arg-validation RFC), so type/required/enum checks are already done and `args` - * is the validated `InferArgs` shape here. What remains are value constraints - * the DSL has no vocabulary for: non-empty strings, a positive finite timeout, - * and the escalation pairing (`sandbox_permissions` and `justification` travel - * together — an approval prompt without a reason, or a reason driving nothing, - * is a malformed ask). + * Validate value constraints absent from SchemaSpec: non-empty strings, a + * positive finite timeout, and paired escalation mode and justification. */ function validateBashArgs(args: BashToolArgs): void { if (args.command.trim().length === 0) { @@ -58,9 +52,7 @@ function validateBashArgs(args: BashToolArgs): void { } /** - * Reject an empty `task_id`. Type and presence are guaranteed by the - * SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the - * DSL can't express, is left to check here. + * Reject an empty `task_id`; SchemaSpec already validates type and presence. */ function validateTaskId(value: string): BashTaskId { if (value.length === 0) { @@ -70,10 +62,8 @@ function validateTaskId(value: string): BashTaskId { } /** - * The bash tool's validated argument shape — the base parameters plus the two - * escalation fields, which are ADVERTISED only when the mounted executor - * reports a confining default mode (absent from the schema otherwise, so the - * SchemaSpec validator rejects them before `execute` ever sees one). + * Validated bash arguments. Escalation fields are advertised only when the + * mounted executor reports a confining mode. */ interface BashToolArgs { command: string @@ -86,10 +76,8 @@ interface BashToolArgs { } /** - * The strictly-wider table: what a call whose effective mode is the key may - * escalate TO. Checked at EXECUTION, never baked into the schema — the - * schema's enum is {@link ESCALATION_TARGETS}, because schemas are - * registry-global while the effective mode is per-call truth. + * Strictly wider modes for each effective mode. Execution checks this table + * because the schema is global while the effective mode is per call. */ const WIDER_MODES: Record = { 'read-only': ['workspace-write', 'danger-full-access'], @@ -97,12 +85,9 @@ const WIDER_MODES: Record = { } /** - * The closed escalation-target vocabulary — every mode a call could ever - * escalate TO (`read-only` is the floor; nothing escalates to it). Advertised - * whenever the mounted executor confines: cutting the enum down to the modes - * wider than the executor's DEFAULT would strand a session whose effective - * mode sits below it (a `danger-full-access` default would advertise nothing - * while a narrower-switched session stays confined with no lever). + * All possible escalation targets. Advertise the global set because a session + * override may be narrower than the executor default; execution rejects a + * target that is not wider for that call. */ const ESCALATION_TARGETS: readonly SandboxMode[] = ['workspace-write', 'danger-full-access'] @@ -121,15 +106,15 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { + 'poll it with `bash_output` and stop it with `bash_kill`.' if (escalationModes.length === 0) return base return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the ' - + 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it ' - + 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry ' + + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' - + 'approval prompt raised by that retry IS how the user consents. If the session states approval ' + + 'approval prompt raised by that retry is how the user consents. If the session states approval ' + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' - + 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one this command ' + 'just hit; escalating up front is fine only when this session already denied the same access. ' - + 'A rejected escalation is final for THAT command — stop and explain, never work around ' + + 'A rejected escalation is final for that command — stop and explain, never work around ' + 'it — but it does not forbid attempting or escalating other commands later.' } @@ -166,15 +151,12 @@ export function renderResult( if (body.length === 0) body = '(no output)' const markers: string[] = [] - // The sandbox marker precedes the exit-status markers so `[exit code: N]` - // stays the LAST line (exitStatus() anchors its parse there). Denial is a - // reported fact like timeout: the model decides how to react. + // Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial, + // like a timeout, remains a reported fact for the model to handle. if (result.sandbox?.denied) { markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`) - // The same-turn nudge lives at the decision point: only when this - // composition advertises the fields (a lever is never hinted that the - // schema does not offer), and inside the sandbox marker family so the - // exit-code marker stays the last line. + // Add the retry hint only when the schema advertises escalation, before + // the final exit marker. if (escalationModes.length > 0) { markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]') } @@ -214,8 +196,7 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView content: [{ type: 'text', text: args.description }], } } - // A foreground run IS a terminal: the command titles the card, the description - // renders above it, and the cwd (when the model gave a workdir) heads it. + // A foreground run is a terminal; an explicit workdir supplies its cwd. return { card: 'terminal', title: args.command, @@ -238,7 +219,7 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | if (isBackground || result.isError) { return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } } - // A finished foreground run: RAW output + parsed exit for the terminal card. + // A finished foreground run supplies raw output and parsed exit status. // The bridge derives the no-capability fenced fallback from `output`. return { card: 'terminal', output: raw, ...parseExitStatus(raw) } } @@ -283,9 +264,7 @@ function statusLine(task: BashTask): string { } export function apply(ctx: Context): void { - // The bash tools' cross-call HABIT, which the per-tool descriptions cannot - // carry (they describe one call each): the exit-code marker is only useful - // if the model actually checks it every time. + // Cross-call guidance belongs in the prompt rather than one tool description. ctx.systemPrompt.section({ name: 'tool:bash', order: 105, @@ -293,26 +272,15 @@ export function apply(ctx: Context): void { }) /** - * The caller's owner TOKEN — the owning agent's `session.header.id`, or - * `undefined` for a non-agent caller. Read `session.header.id` (NOT - * `session.id`): every other subsystem keys off the header id (the ACP bridge, - * both persistence backends), and the sibling `resolveWorkdir` already reads - * `session.header.cwd`, so using `session.id` here would be the asymmetry smell - * the conventions flag. The two are equal in production, but the header is the - * canonical identity. + * Return the canonical session-header id used by ACP and persistence as the + * task owner, or undefined for a non-agent caller. */ const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined => exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined /** - * Authorize a `bash_output`/`bash_kill` call against the task's stored owner - * token. Rejects when the task HAS an owner and it differs from the caller's - * token — using `!== undefined` semantics, NOT truthiness, so an empty-string - * token is still a real owner (never treated as unowned). An unowned task - * (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also - * `undefined` here and then fails loudly at the subsequent - * `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller - * (`callerToken` undefined) cannot match an owned task and is rejected. + * Reject access when a task has a different session owner. Unowned tasks are + * allowed; unknown ids still fail in the subsequent read or kill. */ const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => { const owner = ctx.bash.ownerOf(taskId) @@ -348,32 +316,20 @@ export function apply(ctx: Context): void { const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS /** - * The session's standing mode override for an ordinary (non-escalating) - * call: the `bash/sandbox-mode` fold of the calling agent's log, stamped - * onto the request so EXECUTION follows the same effective mode the prompt - * section states. Weakest precedence — an escalation grant (freshly - * approved for exactly this call) outranks it, and without either the - * executor's `resolve()` applies its configured default. Undefined for a - * non-sandboxing executor (nothing honors it) and for agent-less callers - * (no session to fold). + * Return the calling session's folded standing mode. Approval outranks this + * value and the executor default applies when it is absent; non-sandboxing + * and agent-less calls have no override. */ const sessionOverride = (exec: ToolExecution): SandboxMode | undefined => defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events) /** - * Resolve a sandbox-escalation request through `ctx.approval` BEFORE - * anything executes. Returns the granted mode to stamp onto the bash - * request; throws the distinct fail-closed text for every other path (no - * service composed, an agent-less execution, a rejection, a cancellation, - * an unanswerable ask) — the registry turns the throw into this call's - * isError result, and nothing has run. The seam is consumed - * opportunistically (`ctx.get`, the dsh-tools ask-routing pattern), so a - * deployment without it degrades per call, never at registration. + * Request one-shot escalation before execution. Missing approval context, + * rejection, cancellation, and unavailable answers throw without running the + * command; the optional seam is resolved per call through `ctx.get`. */ const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise => { - // Schema validation only checks ADVERTISED keys, so an unadvertised `sandbox_permissions` - // (no sandboxing executor) still reaches execute — reject it here so a human is never - // prompted to "escalate" a sandbox that is not there. + // Reject an unadvertised escalation before prompting for a nonexistent sandbox. if (escalationModes.length === 0) { throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)') } @@ -399,8 +355,7 @@ export function apply(ctx: Context): void { ...exec.signal ? { signal: exec.signal } : {}, }) switch (outcome) { - // The SchemaSpec enum already pinned `mode` to the closed target - // vocabulary; the per-call check above proved it is strictly wider. + // Schema validation pins the vocabulary; the per-call check proves widening. case 'allowed-once': return mode as SandboxMode case 'rejected': throw new Error(`the user rejected escalating this command to "${mode}"`) case 'cancelled': throw new Error(`approval for escalating to "${mode}" was cancelled`) @@ -457,8 +412,7 @@ export function apply(ctx: Context): void { ...sandboxMode !== undefined ? { sandboxMode } : {}, } if (args.run_in_background === true) { - // Stamp the owner token (the agent's session id) onto the spec so the executor stores - // it on the task — the isolation fence for bash_output/ bash_kill. + // Store the session owner on the task for bash_output/bash_kill isolation. const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) })) return [{ type: 'text', text: `started background task ${task.id}` }] } @@ -492,9 +446,8 @@ export function apply(ctx: Context): void { } text += `\n${statusLine(read.task)}` if (read.task.sandbox?.runnerFailed) { - // The sandbox RUNNER itself failed — the command never ran. The - // foreground path surfaces this as the structured SANDBOX_UNAVAILABLE - // error; a settled task's read carries the marker instead. + // Background settlement carries the runner-failure fact that a + // foreground call exposes as SANDBOX_UNAVAILABLE. text += `\n[sandbox: the sandbox runner itself failed under ${read.task.sandbox.mode} mode — the command did not run; this is a sandbox problem, not a command failure]` } else if (read.task.sandbox?.denied) { // Mirrors the foreground result marker (and its same-turn escalation hint). diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index d44b328f92..0c69674e7a 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -30,3 +30,15 @@ Every field is validated (positive numbers) and defaulted; there are no other tu ## The worker entry, unbuilt and built Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md). + +## Model Experience + +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context. + +## Known Limitations and Deferred Work + +- **OS processes a program spawns survive termination** — `worker.terminate()` ends the thread only, weaker than bash-local's process-group kill; orphan cleanup is a deployment concern until a container backend exists. +- **Type-strip rides Node's experimental `stripTypeScriptTypes` API** — the relied-on behavior is pinned by unit tests, with amaro/sucrase as named drop-in replacements if it shifts. +- **`computeMs` expiry can overshoot by up to one poll interval** — busy time is sampled every 25 ms (an internal constant, deliberately not config). +- **Programs get a five-method `console` shim** (`log`/`info`/`warn`/`error`/`debug`) — deliberately not Node's full console surface. +- **A non-cloneable or oversize completion value does not cross as a value** — it arrives as a bounded, truncation-marked `util.inspect` rendering in `value`'s place. diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 20c9274b9c..94c3bad61c 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -17,3 +17,13 @@ Semantics every implementation must honor (contract details in the class JSDoc): ## Vocabulary `CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts. + +## Model Experience + +Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. + +## Known Limitations and Deferred Work + +- **`run()` is one-shot** — `logs` arrive only on the resolved `CodeRunResult`; the seam exposes no streaming-log or progress surface for a live program's output. +- **A persistent REPL-style kernel is recorded future work** — the no-state-between-runs contract stands until a persistent-kernel backend brings its own logging story ([Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md)). +- **Only the worker-thread backend ships** — `'process'`/`'container'` are declared well-known `isolation` values with no implementation; a hard security boundary awaits a container backend. diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 582c24abad..d9c0f472a8 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -55,3 +55,75 @@ export function apply(ctx: Context): void { ``` Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. + +## Model Experience + +### Conversation history + +**What the model sees**: Before a step whose estimated envelope and history exceed the threshold, the conversation model receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. This one checkpoint replaces the selected older range and is followed by the retained recent units. + +**Token effect**: The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget. + +#### Conversation checkpoint preamble + +```markdown +This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint. +``` + +### Auxiliary summarizer user message + +**What the model sees**: The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. + +**Token effect**: This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. + +### Auxiliary summarizer system prompt + +**What the model sees**: The summarization model receives the checkpoint-writing instruction below. + +**Token effect**: Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. + +#### Auxiliary summarizer system prompt + +```markdown +You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context. + +Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. + +## Primary Request and Intent +- [the user's original and evolving goals; quote verbatim where the exact wording matters] + +## Key Technical Concepts +- [technologies, frameworks, patterns, and conventions in play] + +## Files and Code +- [exact path: why it matters, key changes or snippets] + +## Errors and Fixes +- [error: how it was resolved, plus any related user feedback] + +## Pending Tasks +- [explicitly requested work not yet completed] + +## Current Work +- [precisely what was in progress at this checkpoint] + +## Next Step +- [the single next action, directly in line with the most recent request, or "(none)"] + +## Critical Context +- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue] + +Rules: +- Preserve exact file paths, commands, error strings, identifiers, and function signatures. +- Capture user feedback and explicit instructions faithfully, especially corrections. +- Do NOT mention this summarization process or that the context was compacted. +- If the transcript already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. +``` + +## Known Limitations and Deferred Work + +- **Token estimation is the chars/`charsPerToken` heuristic** — a marked TODO schedules replacing it with an exact count (a real tokenizer, or provider `usage` fed back) so thresholds track the model's actual budget. +- **`estimatePressure()` does not count the request's `tools` field** — pressure is underestimated by the size of the serialized tool schemas the request also carries. +- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting. +- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds. +- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index e98f00899f..9141012281 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| | `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | -| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | +| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). @@ -48,3 +48,24 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati ## Implementing a backend Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A tokenizer-, template-, or model-backed implementation can live as a sibling package without changing callers. + +## Model Experience + +### Conversation history, when a backend is invoked + +**What the model sees**: A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. + +**Token effect**: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged. + +### Transcript supplied to a compaction consumer + +**What the model sees**: `renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. + +**Token effect**: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. + +## Known Limitations and Deferred Work + +- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. +- **Single-unit overflow is out of contract** — one retained unit (a closed step or a large pasted `user/message`) alone exceeding the budget cannot be compacted; the call may go out over-budget. +- **A session prefix that alone approaches the window is a configuration error no backend fixes** — compaction shrinks derived history, never the prefix. +- **Request context injected by downstream `agent/request` listeners sits outside pressure accounting** — `compactIfNeeded` counts prefix, derived history, and system prompt only. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 70c07975f8..f59e0dc328 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -1,7 +1,9 @@ /** - * The compaction service seam (`ctx.compact`): an abstract service defining what compaction - * does — decide when to compact, summarize a range of conversation history into a single - * surface node — without saying how. + * Compaction service seam (`ctx.compact`): implementations decide when to + * compact and replace a history range with one summary node by subclassing + * {@link CompactService}. This interface necessarily depends on session and LLM + * vocabulary; the rationale is in the + * [compaction RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). * @module @deepseek-ai/dsh-compact */ diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index fb1807cf2b..fdc4e51e6b 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -31,3 +31,29 @@ All three tools render `generic` cards (`read` / `execute` / `delete`); `cordis_ ## Export shape Namespace plugin: named exports `name` / `inject` / `Config` / `apply`, no default export ([docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). + +## Model Experience + +### Tool schemas + +**What the model sees**: The conversation model sees the generated [`cordis_inspect`, `cordis_mount`, and `cordis_unmount` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-cordis) whenever this plugin is visible. + +**Token effect**: Fixed schema cost on every request in that tool view. + +### Tool-call history and results + +**What the model sees**: Inspect joins selected sections exactly as `##
` then a newline and the data-dependent body, with one blank line between sections. Mount returns `mounted (plugin "", state: )`, optionally inserting ` — waiting for service(s): (activates when provided)` before the closing parenthesis. Unmount returns `unmounted (plugin "")`; an unknown id becomes `Error: no dynamic plugin with id "" (list mounts with cordis_inspect what:"dynamic")`. The submitted mount program remains in the assistant tool-call history. + +**Token effect**: Inspect output and mount code are data-dependent and resent until compaction; lifecycle acknowledgements are small. + +### Later requests after a mount + +**What the model sees**: A mounted plugin may register tools, prompt contributions, or listeners that change later requests for the scopes it targets; unmount removes those contributions after quiescence. + +**Token effect**: Indirect token impact equals the mounted plugin's contributions and lasts only for the mount lifetime. + +## Known Limitations and Deferred Work + +- **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). +- **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` cover every mount seen so far, and a guarded `effect` waits on a real need (`FIXME(sandbox-effect)`). +- **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index ba547ac6df..fac4f4819a 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -17,7 +17,7 @@ Read this package for the whole plugin tree and its composition order. @deepseek-ai/dsh-skill skill provider registry @deepseek-ai/dsh-skill-local local filesystem skill provider @deepseek-ai/dsh-agent agent registry + agent/* event vocabulary -@deepseek-ai/dsh-invariants dev-mode event-contract assertions +@deepseek-ai/dsh-invariants runtime event-contract assertions @deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas @deepseek-ai/dsh-tool-skill session-prefix skill catalog + model-facing loader schema @deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`) @@ -47,4 +47,13 @@ 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 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. +A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling. + +## Model Experience + +Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, which this bundle mounts without adding model-bound wrapper content. + +## Known Limitations and Deferred Work + +- **The spine set is fixed in code** — `apply()` mounts every child unconditionally (including `tool-bash`); no config excludes or replaces one, so swapping the loop or dropping a spine member means composing a different bundle. +- **`dsh-invariants` mounts unconditionally** — this bundle has no toggle, so every composition using it pays the dev-mode relational assertions; Session's always-on validation and freezing are separate. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index cd6a694cc4..91ed9d5049 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -52,7 +52,7 @@ The driver owns one agent for its lifetime. It records turn, step, request, stre 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 +### What belongs to plugins Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy: - Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md) @@ -61,3 +61,24 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p - Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred. - Persistence: `session/event` + `session/flush` - UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) + +## Model Experience + +### Complete conversation request + +**What the model sees**: For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose. + +**Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence. + +### Retained message history + +**What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded. + +**Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step. + +## Known Limitations and Deferred Work + +- **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`). +- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. +- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. +- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 2394c57b34..5639f30daf 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,8 +50,26 @@ The handle every plugin programs against: - Agent creation: `AgentLoop.create()` is the concrete config-path implementation (in `dsh-agent-loop`), while programmatic consumers create/resume owned agents through `ctx.agents.create()` / `ctx.agents.resume()`. Replace the loop by implementing `Agent` and registering via `ctx.agents.register()`. - Event listeners: all `agent/*` events are declared here — no dependency on the loop package needed. -- Subagent delegation: implemented by `@deepseek-ai/dsh-subagent`, not by a method on `Agent`; providers create or drive ordinary `Agent` handles through the factory seam, so spawn/fork/ACP transports stay outside the core agent interface. +- Subagent delegation is not an `Agent` method; providers create or drive ordinary handles through the factory seam, so delegation transports stay outside the core agent interface. -### What is NOT here (TODO) +## Model Experience + +### User, steering, and injected messages + +**What the model sees**: `send`, `steer`, and `inject` feed the owning session. `agent/prompt-submit`, `agent/session-prefix`, and other declared events let plugins block a prompt or add request material; this interface contributes no fixed prose itself. + +**Token effect**: Accepted content becomes retained history or a repeated session prefix; blocked content contributes no request tokens. Size is caller- and plugin-dependent. + +### Agent-scoped request composition + +**What the model sees**: Registrations through `agent.ctx` can shadow prompt sections or tools and can install agent-only interceptors during unpublished setup. + +**Token effect**: The package adds zero tokens itself; scoped contributions affect only that agent and disappear on disposal. + +## Known Limitations and Deferred Work - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. +- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. +- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). +- **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. +- **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). diff --git a/packages/core/scope/README.md b/packages/core/scope/README.md index a221628f85..36cc059bc4 100644 --- a/packages/core/scope/README.md +++ b/packages/core/scope/README.md @@ -18,3 +18,9 @@ Scoped registration primitive. `createScope(ctx, key)` creates a tagged Cordis c 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. + +## Known Limitations and Deferred Work + +- **Only scope-aware surfaces isolate state** — registries must file by `scopeOf()` and events must dispatch through `scopeTarget()`; an arbitrary Cordis service remains context-global merely because it is called through a scoped context. +- **A context carries one nearest scope key** — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets. +- **Service reachability comes from the scope minter** — handing out `Scope.ctx` also hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 87649d93dc..0c4dfbe9fc 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -79,6 +79,29 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) - 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) +## Model Experience + +### Derived message history + +**What the model sees**: The model receives projections of `user/message`, `assistant/message`, and `tool/result` surface nodes verbatim. A `context/message` is a user-role message containing exactly ``, its content blocks, and ``; `steering/message` uses the identical `` / `` wrapper. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message. + +**Token effect**: Appended surface nodes are resent on later steps. A `replace` surface operation removes the shadowed nodes from future inputs without deleting their raw log records. + +### Crash-repair result + +**What the model sees**: If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.` + +**Token effect**: Zero tokens in an intact session. Each repaired call adds this retained error text on resume. + +### Logged request header + +**What the model sees**: The session reconstructs the system prompt, tool schemas, call config, and session prefix that the loop actually sent. Header events do not add a second copy to message history; the prefix is prepended outside `deriveMessages()`. + +**Token effect**: Zero duplicate tokens from logging. The reconstructed prefix, system text, and schemas still incur their normal per-request cost. + +## Known Limitations and Deferred Work - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. +- **`fork()` cuts only at closed-turn boundaries of live sessions** — the boundary must be a `turn/end` event and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)). +- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 6ff45d2875..e8975bf17e 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -38,9 +38,31 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. - The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. -### What is NOT here - -- Any end-user prompt-editing API — this plugin owns the config-authored global persona default, creator plugins may register agent-scoped shadows during setup, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.) -- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`). - Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). + +## Model Experience + +### System prompt + +**What the model sees**: Every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. + +**Token effect**: Identity is a fixed per-request cost. Persona and plugin text are repeated per request and scale with their rendered content. + +#### Harness identity + +```markdown +You are an AI agent powered by the DeepSeek Harness SDK. +``` + +### Tool schemas + +**What the model sees**: For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#tool-package-map), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance. + +**Token effect**: Schema tokens repeat on every request. Restricting a tool removes its entire schema cost for that agent but not a separate prompt section; reordering changes cache shape but not semantic content. + +## Known Limitations and Deferred Work + +- **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona default, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API. +- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one. +- **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load. +- **Sections sharing an `order` value tie-break by registration order** — a plugin-load artifact; determinism relies on the distinct-order band convention, unlike the canonicalized tool order. diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index ac967265c9..5b2a81528b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -103,7 +103,47 @@ Returning `undefined` selects generic fallback. Presenters depend only on their Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run `additionalContext` is omitted to preserve call/result adjacency. 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) +## Model Experience -- **Concurrency metadata** — tool definitions do not declare whether executions are safe to overlap. -- **Parallel execution** — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists. +### Normal tool schemas + +**What the model sees**: In normal mode the model sees each visible definition's exact name, description, and JSON schema; the shipped definitions are recorded in the generated [tool package map and schema sections](../../../docs/tool-catalog.md#tool-package-map). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. + +**Token effect**: Fixed per-request cost proportional to the visible definitions. Restrictions that hide tools remove their entire schema cost for that agent. + +### Code Mode schema and system prompt + +**What the model sees**: Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface. + +**Token effect**: Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. + +#### Code Mode SDK instructions + +```markdown +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. +- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Calls execute sequentially, even under `Promise.all`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: +``` + +### Tool-call history and results + +**What the model sees**: The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result. + +**Token effect**: Arguments, results, and additional context are data-dependent and resent until compaction. Restrictions that hide tools also remove their schemas before the model can call them. + +## Known Limitations and Deferred Work + +- **Native tool calls execute sequentially** — `ToolDefinition` carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (`TODO(review)`). +- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). +- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input. +- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. +- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. +- **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. +- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index c531096206..7766a4a954 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -19,8 +19,17 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). -## `cwd` is not a sandbox - -`config.cwd` is a resolution default, not a containment boundary — absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. See [the filesystem capability-seam RFC's Consequences section](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences). - The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring. + +## Model Experience + +Indirectly, through [`dsh-tool-fs`](../tool-fs/README.md), which renders this provider's line-windowed UTF-8 content, mutation acknowledgements, and exact provider messages in capped retained results while versions, atomic-write mechanics, and directory metadata remain internal. + +## Known Limitations and Deferred Work + +- **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall ([capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md#consequences)). +- **An overwrite reads the whole prior file into memory** — solely as the UI diff basis; bounding that pre-read above a size threshold is deferred (`TODO(overwrite-diff-bound)`). +- **Version tokens are `mtimeMs:size`** — an external change that preserves both within the filesystem's timestamp granularity defeats the stale guard. +- **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. +- **Binary detection is asymmetric** — reads NUL-sample only the first 8192 bytes while edits scan the whole buffer, so a file with a late NUL reads fine but rejects edits. +- **The per-target mutation lock is in-process only** — a writer in another process is caught only by the optional version guard, never serialized. diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index 0cccb1aa5b..bd85be88cf 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -46,3 +46,18 @@ The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider — this p ## No method coupling Because the plugin influences the world only through events, removing it does not break `@deepseek-ai/dsh-tool-fs` at a service-injection boundary: the tool falls through to the bare `ctx.fs` provider (unconditional write/edit, no observed-state). Loading it back layers the policy on. That graceful add/remove is the whole point of the event gate over a mandatory method service. + +## Model Experience + +### Filesystem tool outcome + +**What the model sees**: This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown. + +**Token effect**: Zero tokens on allowed operations beyond the ordinary tool result. A denial adds the small retained error result and avoids any success payload. + +## Known Limitations and Deferred Work + +- **Observed state does not survive a session resume** — persistence of the `WeakMap` record is deferred, so a resumed session must re-read files before guarded writes/edits. +- **Actors without an agent session can never satisfy the policy** — their edits throw `FS_NOT_OBSERVED` and their writes always resolve `createIfAbsent`, so a non-agent caller cannot overwrite an existing file through the gate. +- **Direct `ctx.fs` reads emit no `fs/observed`** — a file read outside the `read` tool stays unobserved, and a later guarded edit rejects with `FS_NOT_OBSERVED` until the tool reads it. +- **Authorization is version freshness, not view completeness** — any windowed read authorizes a full-file overwrite of an unchanged file, deliberately weaker than a full-view rule ([seam-split RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)). diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index ac2866802b..bad4133b72 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -42,3 +42,14 @@ This package declares three events (see the generated [events catalog](../../../ ## Vocabulary `FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. + +## Model Experience + +Indirectly, through `dsh-tool-fs`, which renders provider text and errors as bounded, retained filesystem tool results. + +## Known Limitations and Deferred Work + +- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md). +- **Seven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing RFC](../../../docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). +- **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index fedd1ff7a2..fc17663e48 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -47,3 +47,59 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve `fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. + +## Model Experience + +### System prompt + +**What the model sees**: Every request in this plugin's registration scope receives the independently registered read, write, and edit guidance below. Scoped tool restrictions can hide schemas without removing these sections. + +**Token effect**: Fixed guidance cost per request while the plugin is active, even when a restriction hides one or more tools. + +#### Read guidance + +```markdown +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. +``` + +#### Write guidance + +```markdown +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. +``` + +#### Edit guidance + +```markdown +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. +``` + +### Tool schemas + +**What the model sees**: The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. + +**Token effect**: Fixed schema cost on every request in that tool view. + +### Read result + +**What the model sees**: A successful read is exactly ``, newline, `file`, newline, ``, numbered lines as `: `, a blank line, one footer, and ``. The footer is exactly `(Output capped. Showing lines -. Use offset= to continue.)`, `(Showing lines - of . Use offset= to continue.)`, or `(End of file - total lines)`. A long line ends exactly `... (line truncated to chars)`. + +**Token effect**: Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; the retained call and result are resent until compaction. + +### Write and edit results + +**What the model sees**: Write returns the exact five-line envelope ``, `file`, ``, `Created file` or `Updated file`, then ``. Edit returns exactly `The file has been updated successfully.` or, for `replace_all`, `The file has been updated. All occurrences were successfully replaced.` The full write or replacement text remains in the assistant tool-call arguments. + +**Token effect**: Success text is small, but large mutation arguments and any result are resent until compaction. + +### Tool errors + +**What the model sees**: Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. + +**Token effect**: Only a failing call adds these retained tokens. + +## Known Limitations and Deferred Work + +- **No directory-listing, glob, grep, or search tools ship** — a deferral of [the tool-schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md); `ctx.fs.listDir` serves provider code such as skill discovery but still has no model-facing consumer, so models fall back to `bash`. +- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. +- **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only (the deliberate [fs-family stance](../README.md)). diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index 2fa5c1c482..843611c2a7 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -35,3 +35,42 @@ Reminders use source-attributed `additionalContext`, preserving the tool's origi ## Testing Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript. + +## Model Experience + +### First-threshold context message + +**What the model sees**: At the first configured consecutive-repeat threshold, that agent receives the reminder below. No tool schema or normal-call text is added. + +**Token effect**: Zero tokens before the threshold. The reminder is retained history for that agent. + +#### First-threshold reminder + +```markdown +You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call. +``` + +### Later-threshold context message + +**What the model sees**: A later threshold receives the detailed reminder template below. A capped argument preview ends exactly `… (+ more chars)`. + +**Token effect**: Each reminder is retained history; `argumentsPreviewChars` bounds its data-dependent argument text, while agents keep independent counters. + +#### Later-threshold reminder + +```markdown +Repeated tool call detected: +- tool: +- consecutive_calls: +- arguments: +The repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered. +``` + +## Known Limitations and Deferred Work + +- **Exact-match detection only** — canonicalization is a deep key-sort, so near-identical variants (a tweaked path, extra whitespace inside a value) evade the chain; fuzzy matching is rejected pending evidence of need. +- **Compaction does not reset chains** — a chain spanning a compaction checkpoint keeps counting. +- **Advisory only** — escalating to `block` at a high threshold is not implemented, though `PostToolDecision` already supports blocking. +- **No subagent chain-sharing** — chains stay isolated per agent; a parent and its subagent repeating the same call never combine. +- **Legitimate idempotent polling still draws nudges** past the thresholds — the pressure valves are `thresholds`/`exclude` config. +- **Past the highest threshold a chain goes silent** — reminders fire only at exact configured counts, never beyond them. diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index c9449bd67a..96a423fea9 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -29,6 +29,11 @@ Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC. -## Input rewrite is parsed but not honored +## Model Experience -`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. +Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback. + +## Known Limitations and Deferred Work + +- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts. +- **An invalid matcher regex matches nothing, silently** — `matchesMatcher` never throws; surfacing the error needs a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`). diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index f97cdb5cfc..efd5df183c 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-hooks-claude -A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns CC's per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md). +A cordis plugin that runs the supported command-hook subset of a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns the bridge's CC-shaped per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md). -A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). +A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only as a compatibility path for the mapped CC command-hook subset**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). ## Config @@ -26,7 +26,7 @@ In a `cordis.yml`: projectDir: . ``` -The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only shell-form `type: 'command'` hooks run; an `http`/`mcp_tool`/`prompt`/`agent` hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. @@ -50,8 +50,28 @@ The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session s Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself. -## Deferred (faithful-but-degraded) +## Model Experience -- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). -- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it. -- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `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-provided context + +**What the model sees**: `SessionStart`, accepted prompt, post-tool, and live in-process subagent-start hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. Remote-child injection has no local target. + +**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent in later conversation requests until compaction. + +### Blocked prompt or tool outcome + +**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. `systemMessage` and `updatedInput` are logged or warned but are not model-visible in this implementation. + +**Token effect**: Blocking a prompt removes that prompt's request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. + +## Known Limitations and Deferred Work + +- **Unsupported hook events (23 of Claude Code's current 30):** `Setup`, `InstructionsLoaded`, `UserPromptExpansion`, `MessageDisplay`, `PermissionRequest`, `PostToolUseFailure`, `PostToolBatch`, `PermissionDenied`, `Notification`, `TaskCreated`, `TaskCompleted`, `StopFailure`, `TeammateIdle`, `ConfigChange`, `CwdChanged`, `FileChanged`, `WorktreeCreate`, `WorktreeRemove`, `PreCompact`, `PostCompact`, `SessionEnd`, `Elicitation`, and `ElicitationResult`. Config for these events is parsed but never dispatched. The comparison baseline is Claude Code's [official hook-event reference](https://code.claude.com/docs/en/hooks#hook-events). +- **`SessionStart` is partial:** JSON `additionalContext` is consumed, but plain stdout context, `initialUserMessage`, `sessionTitle`, `watchPaths`, `reloadSkills`, and `CLAUDE_ENV_FILE` are unsupported. The hook runs detached, so context can miss the first request (`TODO(session-start-gating)`), and the payload omits current optional fields such as `model`, `agent_type`, and `session_title`. +- **`UserPromptSubmit` is partial:** blocking and JSON `additionalContext` work, but plain stdout context, `sessionTitle`, and `suppressOriginalPrompt` are unsupported. Unless overridden, the bridge also uses its 600-second default instead of Claude Code's event-specific 30-second command timeout. +- **`PreToolUse` is partial:** `deny` and `ask` decisions work; `allow` does not pre-approve, `defer` is unsupported, `additionalContext` is ignored, and `updatedInput` is logged + warned but not honored ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). +- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `updatedToolOutput` and `updatedMCPToolOutput` are unsupported and `tool_response` is flattened to text. +- **`SubagentStart` and `SubagentStop` are partial:** both report a constant `agent_type` of `general-purpose` and use the child session id where Claude Code reports the parent session. Start context is best-effort and can only reach a live in-process child, while stop is observe-only and cannot block the subagent or feed it context. Start omits `transcript_path`; stop also omits `agent_transcript_path`, `last_assistant_message`, `background_tasks`, and `session_crons` and always reports `stop_hook_active: false`. +- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message`, `background_tasks`, and `session_crons` are omitted, and the consecutive-block cap is not implemented (`TODO(stop-loop-guard)`). An unconditionally blocking hook therefore force-continues every step unless it self-limits. +- **Common payload and output fields are partial:** mapped event payloads omit `prompt_id`, `transcript_path`, `permission_mode`, and `effort` where Claude Code would provide them. `systemMessage` is logged + warned but not surfaced; `{"continue": false}` is recorded but does not halt the run; `suppressOutput`, `stopReason`, and `terminalSequence` are not applied (`TODO(hook-continue-false)`). +- **Handler and config support is partial:** only shell-form command handlers run. `http`, `mcp_tool`, `prompt`, and `agent` handlers are skipped; command-handler options such as `args`, `async`, `asyncRewake`, `shell`, `if`, `once`, and `statusMessage` are not honored. Matching handlers run serially and are not deduplicated, whereas Claude Code runs them in parallel and deduplicates identical handlers. One process-level `configPath` is parsed once at load; Claude Code's layered project, user, plugin, and policy discovery and live reload are not implemented (`TODO(per-session-hook-config)`). diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 9e8718fbb5..3797d4e56f 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -1,7 +1,8 @@ /** * Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s. - * Command hooks run after `${CLAUDE_PLUGIN_ROOT}` substitution. Other supported hook types are - * parsed but skipped with a warning, matching the bridge's faithful-but-degraded policy. + * Only command hooks run; other hook types are returned as skipped so the + * bridge can warn. Plugin-root and project-directory substitutions are applied + * to commands at parse time. * @module @deepseek-ai/dsh-hooks-claude/config */ diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 755edc99a2..08a2d26c9d 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -1,9 +1,11 @@ /** * Bridge for unmodified Claude Code command hooks on harness interception * seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent - * start/stop; owns Claude payloads, environment and plugin-root substitution; - * and logs but does not honor `updatedInput`. Bespoke behavior should use typed - * native plugins on the same seams. + * start/stop. It owns Claude payloads, environment, substitution, and decision + * mapping; shared execution and parsing live in `dsh-hook-protocol`. + * `updatedInput` is logged and warned but not honored. Bespoke behavior should + * use typed native plugins on the same seams; see the + * [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-claude */ @@ -41,7 +43,7 @@ export const inject = ['bash'] export interface Config { /** * Path to a `hooks.json` or a settings file whose `hooks` key holds the config. - * PROCESS-LEVEL: read once at load, a relative path resolves against the process + * Process-level: read once at load, a relative path resolves against the process * launch cwd, so one config applies to the whole process. * TODO(per-session-hook-config): per-session discovery of a project-local * `hooks.json` from each `session/new.cwd` is not yet implemented. @@ -90,14 +92,11 @@ function assertPositiveInteger(name: string, value: number): void { } export function apply(ctx: Context, config: Config): void { - // Validate the cap BEFORE the config-file parse: a bad value must fail the - // load loudly, not be skipped by the parse-failure early return. + // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS - // --- Parse the config ONCE at load. A read/parse failure is contained: the - // bridge logs and registers nothing rather than crashing boot (a typo'd path - // must not take the agent down). --- + // Parse once at load. A read or parse failure logs and registers nothing. let parsed: ClaudeHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) @@ -114,10 +113,8 @@ export function apply(ctx: Context, config: Config): void { return } - // --- The emit-shaped points (SessionStart, SubagentStart, SubagentStop) run detached — no - // seam awaits them — so every run chain is tracked and disposal aborts still-running hook - // processes, then drains the continuations (docs/defensive-patterns.md: dispose must reach - // quiescence). + // Emit-shaped points run detached, so track their chains; disposal aborts + // active hooks and drains continuations before resolving. const detached = createDetachedRuns() ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs') @@ -138,11 +135,11 @@ export function apply(ctx: Context, config: Config): void { ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] - // Run the hook in the AGENT'S session workspace (the `session/new` cwd on the session + // Run the hook in the agent's session workspace (the `session/new` cwd on the session // header), not the executor default (the ACP server's launch dir). const workdir = opts.agent?.session.header.cwd // CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session - // workspace (the same dir the hook RUNS in). + // workspace (the same dir the hook runs in). const projectDir = config.projectDir ?? workdir const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined for (const group of groups) { diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 06ec7ad266..506d613c73 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -15,11 +15,8 @@ import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL - * bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook - * scripts written to a temp dir — only the model is mocked (the "prefer the real - * implementation" rule). Each test writes a `hooks.json` + executable scripts, - * loads the bridge pointed at them, and asserts the hook's effect on the loop. + * Full-loop Claude bridge tests with a mock model, the real loop and bash + * executor, and shell hooks from a temporary config. */ const dirs: string[] = [] @@ -193,7 +190,6 @@ describe('hooks-claude bridge — PostToolUse', () => { await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') - // PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback. expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true) }) @@ -331,8 +327,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Disposal reaches quiescence: it returns only after the aborted run settles and the process // is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. expect(() => process.kill(pid, 0)).toThrow() - // The aborted run resolves as a non-blocking error (runHook never rejects), - // so the drained continuation must NOT have logged a failure. + // runHook resolves an aborted run as a non-blocking error, so draining must + // not log a rejected continuation. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) }) diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index b3fbc39928..06cafe1297 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -1,16 +1,16 @@ # @deepseek-ai/dsh-hooks-codex -A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping. +A cordis plugin that runs the supported subset of a user's existing **Codex** hook config on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-shaped payloads, matcher mode, and decision mapping. -Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape): +This bridge implements a deliberate subset of Codex's current hook protocol: -- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks. +- **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`. - **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex). - **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline. -- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim). -- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve. +- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell). +- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input. -A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). +A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only as a compatibility path for the mapped Codex subset (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)). ## Config @@ -32,7 +32,7 @@ In a `cordis.yml`: model: deepseek-v4 ``` -The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse. +The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse. The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir. @@ -54,8 +54,27 @@ A tool call's payload carries the real `tool_name` (the same value the matcher t Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`). -## Deferred +## Model Experience -**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands. +### Hook-provided context -**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`). +**What the model sees**: `SessionStart`, accepted prompt, and post-tool hooks can add source-attributed context messages; a blocking `Stop` hook adds its reason as next-step steering. + +**Token effect**: No cost when hooks return no context. Hook text is data-dependent, logged, and resent until compaction. + +### Blocked prompt or tool outcome + +**What the model sees**: Provider-supplied reasons pass through verbatim. When absent, a blocked prompt uses exactly `blocked by UserPromptSubmit hook`, a denied tool becomes `Error: blocked by PreToolUse hook`, blocked post-tool feedback is exactly `blocked by PostToolUse hook`, and a blocking stop adds steering exactly `continue: blocked by Stop hook`. Codex `systemMessage` is not surfaced. + +**Token effect**: Blocking a prompt removes its request tokens; denial or feedback adds the retained fallback or provider text; forced continuation pays another full request. + +## Known Limitations and Deferred Work + +- **Unsupported hook events (5 of Codex's current 10):** `PermissionRequest`, `PreCompact`, `PostCompact`, `SubagentStart`, and `SubagentStop`. Config for these events is silently dropped during parsing. The comparison baseline is Codex's [official hook reference](https://learn.chatgpt.com/docs/hooks). +- **`SessionStart` is partial:** plain stdout and JSON `additionalContext` work, but the hook runs detached, so context can miss the first request (`TODO(session-start-gating)`). +- **`UserPromptSubmit` is partial:** blocking plus plain-stdout or JSON context work, but the common `systemMessage` and `{"continue": false}` controls are not enforced. +- **`PreToolUse` is partial:** blocking works, but `additionalContext`, `permissionDecision: "allow"`, and `updatedInput` are ignored. Every tool is represented as `tool_input: { command }`, so non-shell tool arguments are not faithfully exposed to the hook. +- **`PostToolUse` is partial:** blocking feedback and JSON `additionalContext` work, but `{"continue": false}` is not enforced, non-shell tool arguments are reduced to `{ command }`, and structured tool output is flattened to text in `tool_response`. +- **`Stop` is partial:** blocking forces another model turn, but `stop_hook_active` is always `false`, `last_assistant_message` is always `null`, and `{"continue": false}` is not enforced. An unconditionally blocking hook therefore force-continues every step unless it self-limits (`TODO(stop-loop-guard)`). +- **Common payload and output fields are partial:** every mapped event reports `transcript_path: null`, the statically configured `model`, and `permission_mode: "default"` instead of current Codex runtime values. `systemMessage` is logged + warned but not surfaced, and `{"continue": false}` is recorded but does not apply Codex's event-specific stop behavior (`TODO(hook-continue-false)`). +- **Config loading and execution are partial:** one process-level `configPath` is parsed at load; Codex's active user, project, session, system/managed, and plugin layers, trust controls, and inline `config.toml` hook form are not implemented (`TODO(per-session-hook-config)`). Only synchronous `command` handlers run, current metadata such as `statusMessage` and `commandWindows` is ignored, and matching handlers run serially rather than with Codex's concurrent launch semantics. diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 639ee27ab8..e602ddb20c 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -7,7 +7,7 @@ import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol' -/** The five hook points Codex's engine supports. */ +/** The five Codex hook points this bridge supports. */ export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const /** A parsed Codex config: event name → its matcher groups (command hooks only). */ diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 8d3ac3415b..924b181151 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -2,7 +2,10 @@ * Bridge for unmodified Codex command hooks on harness interception seams. It * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only * matchers, snake_case payloads without a trailing newline, no hook environment - * or command substitution, and block-only decisions; allow/ask do not grant. + * or command substitution, and no pre-tool approval or rewrite path; only + * blocking decisions are honored. Shared execution and parsing live in + * `dsh-hook-protocol`; see the + * [hook-bridges RFC](../../../../docs/rfc/implemented/feature/2026-06-30-hook-bridges.md). * @module @deepseek-ai/dsh-hooks-codex */ @@ -37,7 +40,7 @@ export const inject = ['bash'] /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** - * Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative + * Path to a Codex `hooks.json`. Process-level: read once at load, a relative * path resolves against the process launch cwd. * TODO(per-session-hook-config): per-session project-local discovery from each * `session/new.cwd` is not yet implemented. @@ -73,8 +76,7 @@ function assertPositiveInteger(name: string, value: number): void { } export function apply(ctx: Context, config: Config): void { - // Validate the cap BEFORE the config-file parse: a bad value must fail the - // load loudly, not be skipped by the parse-failure early return. + // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS @@ -107,12 +109,11 @@ export function apply(ctx: Context, config: Config): void { ): Promise { const groups: MatcherGroup[] = parsed[point] ?? [] const outputs: HookOutput[] = [] - // Run the hook in the agent's session workspace (the `session/new` cwd), not the executor - // default (the server launch dir) — a hook reading a relative file or `pwd` must see the - // user's project tree. + // Run hooks in the agent's session workspace so relative paths address the + // user's project rather than the server launch directory. const workdir = opts.agent?.session.header.cwd for (const group of groups) { - // Codex matches with PURE regex (no literal fast path). + // Codex always interprets matchers as regexes; it has no literal fast path. if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue for (const hook of group.hooks) { const handlerId = nextHandlerId(point) @@ -128,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { defaultTimeoutMs, ...workdir !== undefined ? { cwd: workdir } : {}, ...opts.signal ? { signal: opts.signal } : {}, - trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline. + trailingNewline: false, // Codex writes stdin without a trailing newline. // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, }, () => performance.now()) @@ -181,7 +182,7 @@ export function apply(ctx: Context, config: Config): void { /* jscpd:ignore-end */ }) - // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask). + // UserPromptSubmit → PromptDecision. Codex supports block, not allow or ask. ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) @@ -232,9 +233,9 @@ export function apply(ctx: Context, config: Config): void { }) // Stop → ContinuationDecision. A blocking Stop hook forces continuation. - // TODO(stop-loop-guard): like CC, a Stop hook that unconditionally blocks would - // force-continue every step (`stop_hook_active` is always false here); the - // loop-guard (stop_hook_active + a max-consecutive cap) is deferred. + // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can + // avoid continuing the same turn indefinitely. It is always false here, so an + // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) /* jscpd:ignore-end */ diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 0deb84f5b3..d4a5797b96 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -15,10 +15,9 @@ import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' /** - * Full-loop Codex-bridge tests: scripted mock MODEL + REAL loop + REAL bash + - * REAL `dsh-hooks-codex` running REAL shell scripts from a temp `hooks.json`. - * Codex dialect specifics exercised here: regex matcher (substring), block-only - * decisions, the five-event subset. + * Full-loop Codex bridge tests with a mock model, the real loop and bash + * executor, and shell hooks from a temporary config. Covers regex matching, + * block-only decisions, and the five-event subset. */ const dirs: string[] = [] @@ -90,36 +89,30 @@ describe('hooks-codex bridge', () => { const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('codex blocked it'))).toBe(true) - // recorded under the codex dialect expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.dialect === 'codex' && e.data.point === 'PreToolUse')).toBe(true) }) it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => { const dir = configDir() - // Block exactly ONCE (a marker file), then allow — without a one-shot guard a - // hook that always exits 2 would force-continue forever (the deferred - // stop_hook_active loop-guard is the real fix; here we self-limit so the test - // exercises the continue path without looping). + // Block once with a marker; until the loop guard lands, an always-blocking + // hook would never let this test finish. const marker = join(dir, 'fired') const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`) writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] }) - // Step 1 has no tool calls → would stop; the Stop hook forces step 2. const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // The Stop hook's reason became next-step steering → a second model request ran. expect(adapter.requests).toHaveLength(2) expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going: address the goal') }) - it('only the five Codex events are honored — a SubagentStop entry is ignored', async () => { + it('only the five bridge-supported Codex events are honored — a SubagentStop entry is ignored', async () => { const dir = configDir() const s = script(dir, 'x.sh', '#!/usr/bin/env bash\nexit 2\n') - // SubagentStop is NOT a Codex event; it must be dropped (no crash, no effect). writeHooks(dir, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('fine')]) @@ -127,7 +120,6 @@ describe('hooks-codex bridge', () => { const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) - // Ran normally; the unknown event was dropped at parse. expect(adapter.requests).toHaveLength(1) }) @@ -193,8 +185,8 @@ describe('hooks-codex bridge', () => { // Disposal reaches quiescence only after the aborted run settles and the process is reaped, so // `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. expect(() => process.kill(pid, 0)).toThrow() - // The aborted run resolves as a non-blocking error (runHook never rejects), - // so the drained continuation must NOT have logged a failure. + // runHook resolves an aborted run as a non-blocking error, so draining must + // not log a rejected continuation. expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) }) diff --git a/packages/hooks/hooks-codex/tests/config.spec.ts b/packages/hooks/hooks-codex/tests/config.spec.ts index e79d665931..09bce12a43 100644 --- a/packages/hooks/hooks-codex/tests/config.spec.ts +++ b/packages/hooks/hooks-codex/tests/config.spec.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from 'vitest' import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts' describe('parseCodexConfig', () => { - it('honors only the five Codex events, dropping unknown ones', () => { + it('honors only the five bridge-supported Codex events, dropping the rest', () => { const { config } = parseCodexConfig({ PreToolUse: [{ hooks: [{ type: 'command', command: 'a.sh' }] }], - SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // not a Codex event - Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // not a Codex event + SubagentStop: [{ hooks: [{ type: 'command', command: 'b.sh' }] }], // current Codex event, unsupported by this bridge + Notification: [{ hooks: [{ type: 'command', command: 'c.sh' }] }], // unknown to current Codex }) expect(Object.keys(config)).toEqual(['PreToolUse']) expect(CODEX_EVENTS).toContain('PreToolUse') @@ -18,7 +18,7 @@ describe('parseCodexConfig', () => { Stop: [{ hooks: [{ type: 'command', command: '${NOT_SUBSTITUTED}/s.sh', timeout: 10 }] }], UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'u.sh', timeoutSec: 20 }] }], }) - // Codex does NO substitution — the literal ${…} survives. + // The parser performs no config-time substitution; shell expansion happens later. expect(config.Stop).toEqual([{ hooks: [{ command: '${NOT_SUBSTITUTED}/s.sh', timeoutSec: 10 }] }]) expect(config.UserPromptSubmit).toEqual([{ hooks: [{ command: 'u.sh', timeoutSec: 20 }] }]) }) diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 15fad9f881..1270ad40a5 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -34,10 +34,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. -## Limitations (MVP, documented deliberately) - -- `tool_choice` is not mapped (not part of the core vocabulary). - ## Errors Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `INVALID_REQUEST` (400), `SERVER` (5xx), `HTTP_` otherwise. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: }` chunks. @@ -45,3 +41,23 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LI ## Testing Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback. + +## Model Experience + +### DeepSeek request + +**What the model sees**: The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. + +**Token effect**: Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. + +### DeepSeek response + +**What the model sees**: Reasoning, text, and raw-string tool arguments are translated into harness chunks for the loop to log and assemble. + +**Token effect**: Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. + +## Known Limitations and Deferred Work + +- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). +- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). +- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f74ccd2246..dd1cb5b48c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -33,10 +33,28 @@ Every request carries the shared attribution header from dsh-llm's `attributionH pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification. -## Limitations - -Same MVP contract as llm-deepseek: `tool_choice` is not mapped. - ## Testing Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK happily talks to any base URL). Real-API coverage in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across all exposed reasoning levels (off/high/xhigh), the thinking+tools round trip, and a cross-adapter structural-equivalence check against llm-deepseek. + +## Model Experience + +### DeepSeek request through pi-ai + +**What the model sees**: The selected model receives the same logical system prompt, history, tools, stop sequences, and raw replayed tool arguments as the hand-written adapter. This package adds no prompt prose and removes pi-ai's own per-tool `strict` default to preserve that contract. + +**Token effect**: Provider tokenization governs exact input. Reasoning level changes generated and passback content; pi-ai reports reasoning inside output usage rather than as a separate count. + +### DeepSeek response + +**What the model sees**: pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks; parsed tool arguments are restored to raw JSON strings at the harness boundary. + +**Token effect**: Generated content affects later inputs only after the loop records it; adapter conversion adds no model-visible text. + +## Known Limitations and Deferred Work + +- **`tool_choice` is not mapped** — same MVP contract as llm-deepseek. +- **In-history `system`-role messages fold into `user`-role wire messages** — pi-ai exposes a single `systemPrompt` slot, diverging from the hand-rolled twin's `role: 'system'` passthrough. +- **`LlmError.status` is never set** — pi-ai reports failures as in-stream events with no HTTP status, so error codes are regex-classified from the error text. +- **`buildModel` hardcodes descriptor metadata** — `contextWindow: 128000`, `maxTokens: 64000`, zero cost, identically for every registered model name; not configurable. +- **pi-ai's built-in retries are disabled (`maxRetries: 0`)** — failures surface immediately; retry policy belongs to `llm/stream` listeners. diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 1c0f8df41b..296fd0c3d3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -47,3 +47,16 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) uses `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. + +## Model Experience + +None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message. + +## Known Limitations and Deferred Work + +- **No retry/caching/rate-limit layer ships** — `llm/stream` is the intended wrap seam and has no production listener, so provider 429/5xx failures surface immediately. +- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)). +- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([RFC](../../../docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)). +- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw. +- **`APP_IDENTITY.url` names a repository that does not exist yet** — `FIXME`: creating the public `deepseek-ai/deepseek-harness-sdk` repo gates the first release. +- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would cycle; a future ids-owning package would dissolve the workaround. diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 236de0de43..fa0ff7257f 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -18,3 +18,15 @@ Each rung has a self-skipping keyless world-effect test; CI runs platform legs a ``` Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/); see [the acp-agent example](../../../examples/acp-agent/) for the runnable default composition. + +## Model Experience + +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), which render this provider's enforcement and denial facts while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection and profiles stay outside context. + +## Known Limitations and Deferred Work + +- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred. +- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. +- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. +- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. +- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly. diff --git a/packages/sandbox/sandbox/README.md b/packages/sandbox/sandbox/README.md index ef381625b2..93e274b485 100644 --- a/packages/sandbox/sandbox/README.md +++ b/packages/sandbox/sandbox/README.md @@ -9,3 +9,24 @@ Policy rides the call, not the provider: two consumers may confine under differe **Same-world confinement only.** A backend shares the host's filesystem and kernel (`bwrap`, Landlock, Seatbelt); `workspaceRoot` names a real host path. Containers, microVMs, and remote executors are NOT backends of this seam — they replace whole capability implementations (`ctx.bash`, `ctx.fs`) as environment-coherent groups. The boundary and its rationale: [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). Implementations: [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) (Linux: `bwrap`, else the per-platform Landlock launcher; macOS: `sandbox-exec`/Seatbelt). Consumers: [`@deepseek-ai/dsh-bash-sandbox`](../../bash/bash-sandbox/) (wraps `['bash', '-c', command]`). + +## Model Experience + +### Confinement error, indirectly + +**What the model sees**: Through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md) and [`dsh-tool-bash`](../../bash/tool-bash/README.md), failure to enforce a requested mode produces code `SANDBOX_UNAVAILABLE` and the exact error below. An execution-time runner failure adds ` Runner failure: `. + +**Token effect**: Conditional error text is visible for that call and retained in history until compaction. + +#### Exact error + +```markdown +sandbox mode "" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS) — Windows has no confinement backend yet — or switch the consumer to danger-full-access. +``` + +## Known Limitations and Deferred Work + +- **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions. +- **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here. +- **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output. +- **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity. diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 563120283d..75cea5ecfe 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -70,20 +70,17 @@ export interface ConfinedArgv { } /** - * Error `code` carried by the infrastructure error a provider throws when a - * confined policy is requested but no backend is available or usable on this - * host: confinement FAILS CLOSED (refuses to run) rather than silently - * executing unconfined. Thrown as a `HarnessError`, it reaches the model - * through the structured `{ name, code }` error channel on `tool/result`, so - * callers can distinguish "the sandbox is missing" from a failing command. + * Error code for a requested confined mode when no backend is usable. The + * provider fails closed, and `HarnessError` carries the code through + * `tool/result` so callers can distinguish missing confinement from command + * failure. */ export const SANDBOX_UNAVAILABLE = 'SANDBOX_UNAVAILABLE' /** - * Thrown by {@link SandboxProvider.confine} when a confined policy is - * requested but no backend is usable on this host: confinement fails closed. - * Carries the {@link SANDBOX_UNAVAILABLE} code through the structured - * `{ name, code }` error channel. + * Thrown when {@link SandboxProvider.confine} cannot enforce the requested + * mode. Carries {@link SANDBOX_UNAVAILABLE} through the structured error + * channel. */ export class SandboxUnavailableError extends HarnessError { constructor(mode: ConfinedSandboxMode, detail?: string) { diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 4301d0f469..bd805704aa 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -21,12 +21,26 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ## Durability and crash semantics -- **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`. +- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **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.** `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 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. + +## Model Experience + +### Resumed conversation history + +**What the model sees**: JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages. + +**Token effect**: Zero live-request tokens. A resumed agent pays for retained history and its current envelope, plus the quoted repair result for each interrupted call. + +## Known Limitations and Deferred Work + +- **Only the current `SESSION_FORMAT_VERSION` (v0) loads** — the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 and non-current logs are rejected; there is no migration. +- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). +- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated. +- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 820a185712..d5922afa94 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -29,13 +29,7 @@ export interface Config { root: string } -/** - * Whether `error` is a "no such file/directory" (`ENOENT`) failure — the ONLY - * filesystem error that legitimately means "this session/root is absent" for a - * durable backend. Any OTHER error (`EACCES`, `ENOTDIR`, transient I/O) must - * surface rather than be silently reported as absence. (A NodeJS filesystem - * rejection carries a string `code`.) - */ +/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' } @@ -53,13 +47,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi }) /** - * Backend label for the coordinator's dispose-failure AggregateError and - * effect name. NOTE: this intentionally shadows cordis `Service.name` (which - * the base sets to `'sessionPersistence'`). The service is registered under the - * fixed key the Service constructor captured (`reflect.provide('sessionPersistence', …)`), - * not via `this.name`, so overwriting the instance field with the backend label - * does not affect `ctx.sessionPersistence` resolution — it only relabels the - * dispose diagnostics, which is exactly what {@link PersistenceBackend.name} is for. + * Backend label for coordinator diagnostics and effects. It shadows + * `Service.name` without changing the service key captured by the base + * constructor. */ override readonly name = 'session-persistence-jsonl' @@ -104,7 +94,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- PersistenceBackend hooks (the file-bytes storage primitives) --- - /** Read a stored prefix by id across ALL cwd buckets (cwd unknown). */ + /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ async loadStored(id: SessionId): Promise | undefined> { const file = await this.findLog(id) if (file === undefined) return undefined @@ -112,11 +102,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Read a stored prefix SCOPED to `cwd` (HMR live-adoption must not cross cwd). - * `undefined` is the DEFINITE "no-cwd" bucket, NOT "unknown" — a live session - * with no cwd may only adopt a persisted no-cwd log, never a same-id log that - * lives in some other cwd bucket. So this looks at exactly `logPath(cwd)` - * (which maps `undefined` → the `_no-cwd` bucket), never the all-buckets scan. + * Read a stored prefix within one cwd for HMR adoption. `undefined` names the + * no-cwd bucket rather than an unknown cwd, so this never scans other buckets. */ async loadLive(id: SessionId, cwd: string | undefined): Promise | undefined> { const path = logPath(this.root, cwd, id) @@ -125,10 +112,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Read and scan a session's log file into a {@link StoredPrefix}. Folds the - * torn-tail comparison HERE so the `tornMarker` is the byte offset to truncate - * to (or `undefined` when nothing is torn) — the coordinator never sees the - * raw byteLength. + * Read a stored prefix and convert torn-tail state to the byte offset the + * coordinator can round-trip without knowing the file format. */ private async readPrefix(path: string): Promise> { const buffer = await readFile(path) @@ -164,9 +149,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi const metas: SessionHeader[] = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { - // Read ONLY the header line, not the whole log: a session picker must - // scale with the number of sessions, not the total size of every - // conversation (the log persists every assistant/chunk verbatim). + // Read only headers so listing scales with session count, not log size. const first = await this.readFirstLine(`${dir}/${name}`) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) @@ -179,7 +162,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- materialization / append / repair (file mechanics) --- - /** Atomically write the header line + first batch (temp-write, fsync, rename). */ + /** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */ private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const dir = sessionDir(this.root, meta.cwd) await mkdir(this.root, { recursive: true, mode: 0o700 }) @@ -204,9 +187,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } finally { await handle.close() } - // Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the - // final path already exists, so two processes materializing the same id - // concurrently cannot clobber each other. rename() would silently overwrite. + // Publish with link()+unlink(): unlike rename(), link fails if another + // process materialized the same id first. let linked = false try { await link(tmp, finalPath) @@ -217,9 +199,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ if (!linked) await rm(tmp, { force: true }) } - // link() succeeded — the log is published. fsync the directory so the new - // entry survives a power loss: the new link is not crash-durable until the - // parent directory's metadata is synced. + // The published link becomes crash-durable only after its directory fsync. await this.syncDir(dir) // Best-effort temp cleanup: the log is already published and durable, so a failure to // remove the (now-redundant) temp hard link must not reject the append. @@ -230,7 +210,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** fsync a directory so a just-created/renamed entry inside it is crash-durable. */ + /** fsync a directory so a just-created or published entry inside it is crash-durable. */ private async syncDir(dir: string): Promise { const handle = await open(dir, 'r') try { @@ -241,11 +221,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Append event lines at EOF and fsync. On a write/sync failure AFTER the kernel - * accepted some bytes (ENOSPC, an fsync error), truncate the file back to its - * pre-append size before rethrowing: the cursor is unchanged, so the batch will - * be retried, and without this rollback the retry would append AFTER the partial - * bytes — producing duplicate seqs that make `scanLog` see a gap. + * Append and fsync event lines. On a partial write or sync failure, restore the + * previous size before rethrowing because the unchanged cursor will retry the + * batch; leaving partial bytes would create duplicate sequence numbers. */ private async appendLines(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const path = logPath(this.root, meta.cwd, meta.id) @@ -307,10 +285,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** - * Find a session's log file by id across ALL cwd buckets — the any-cwd scan - * for `loadStored` (resume identifies a session by id alone). The cwd-scoped - * lookup (`loadLive`) does NOT use this; it goes straight to `logPath(cwd)` so - * a no-cwd session can't match a real-cwd bucket. + * Find a session by id across cwd buckets for resume. Cwd-scoped HMR adoption + * bypasses this scan so a no-cwd session cannot claim another bucket. */ private async findLog(id: SessionId): Promise<{ path: string; cwd: string | undefined } | undefined> { const target = encodeSegment(id) + '.jsonl' diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index ea209e6a34..1411e177be 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -28,3 +28,18 @@ interface Config { ## Write path Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown. + +## Model Experience + +### Resumed conversation history + +**What the model sees**: SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages. + +**Token effect**: Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call. + +## Known Limitations and Deferred Work + +- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers. +- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. +- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). +- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index b0c2202b8c..f09a21251b 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -47,3 +47,17 @@ Three backends run these suites: an in-memory reference (in `tests/`), `dsh-sess ## Metadata types Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`). + +## Model Experience + +### Resumed conversation history + +**What the model sees**: This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call. + +**Token effect**: Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text. + +## Known Limitations and Deferred Work + +- **No deletion or retention surface** — the seam is `create`/`append`/`load`/`list` only; pruning stored sessions is out-of-band backend maintenance. +- **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. +- **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 55f9b32fcd..f2293cbbee 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -2,8 +2,6 @@ Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. -This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. - ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. @@ -20,4 +18,11 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis |---|---:|---| | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +## Model Experience + +None, as this trusted query service returns cloned session records only to its callers and registers no model-facing prompt, schema, tool, or message. + +## Known Limitations and Deferred Work + +- **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect. +- **Exact retrieval only** — filters, lineage/provenance traversal, extraction, search-provider protocol, index synchronization, and a model-facing tool are absent. Full-text search belongs beside its first implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index 4b661d45b0..9449a40ec5 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -35,3 +35,14 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read ## Skill Format Skills can be single-level directory bundles (`/SKILL.md`) or flat Markdown files (`.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case. + +## Model Experience + +Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the session-prefix catalog and a selected instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden. + +## Known Limitations and Deferred Work + +- **Discovery is one level deep** — only `//SKILL.md` and `/.md` are recognized; nested skill trees and package manifests are ignored. +- **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection. +- **Unreadable or malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from a skipped one. +- **No filesystem watching** — edits rely on the registry cache being evicted or invalidated by provider reload before a previously collected cwd is rediscovered. diff --git a/packages/skill/skill/README.md b/packages/skill/skill/README.md index 16f31da4dc..8edcd71ef0 100644 --- a/packages/skill/skill/README.md +++ b/packages/skill/skill/README.md @@ -34,3 +34,14 @@ Contract violations fail fast. A rejected `list()` is treated as a transient sou ## Consumer boundary The registry does not render model guidance or register model-facing tools. [`@deepseek-ai/dsh-tool-skill`](../tool-skill) consumes `ctx.skills` to provide the session-prefix catalog and `skill` tool, so providers remain independent of the model surface. + +## Model Experience + +Indirectly, through `dsh-tool-skill`, which renders provider summaries into the session prefix and loaded instructions into retained tool results. + +## Known Limitations and Deferred Work + +- **Completed catalogs have no TTL or watcher invalidation** — a provider's underlying files or remote data can change without a registration revision, so a cached cwd stays stale until eviction or provider/runtime reload. +- **Providers are queried sequentially** — one slow cooperative provider delays every provider registered after it; cancellation stops the caller's wait but cannot terminate work an uncooperative provider keeps running. +- **A provider-list failure removes that whole source for the request** — the registry logs and skips it, with no model-visible diagnostic or partial-catalog recovery contract. +- **Duplicate resolution is first-wins** — later lower-priority candidates are logged and hidden; there is no API to inspect all shadowed definitions. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index aa5f669924..a2c7ff5c40 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -23,3 +23,92 @@ Resource guidance resolves only paths or URLs explicitly referenced by the instr An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. + +## Model Experience + +### Session prefix + +**What the model sees**: If model-invocable skills exist and this exact `skill` tool is visible, the agent receives the catalog template below, with one data-dependent entry per sorted skill. The catalog is a frozen user-role session prefix. + +**Token effect**: Repeated input cost scales with skill count and `catalogDescriptionMaxLength`; no catalog tokens are sent when the list is empty or the tool is hidden or shadowed. + +#### Skill catalog template + +```markdown + +A skill is a reusable set of task-specific instructions. The following skills are available in this session: + + +- ``: + + +If the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded. + +``` + +### Tool schema + +**What the model sees**: The model sees the generated [`skill` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill). + +**Token effect**: Fixed schema cost per request where the tool is visible. + +### Tool result + +**What the model sees**: A successful call uses the result template and the provider-managed, directory, URL, or opaque resource guidance below. + +**Token effect**: Loaded instructions are data-dependent tool-result tokens, resent on later steps until compaction; no duplicate `agent.inject()` copy is made. + +#### Skill result template + +```markdown + + + + + + + + + +``` + +#### Provider-managed resource guidance + +```markdown +Resources for this skill are managed by provider "". +Load referenced resources only as needed. +``` + +#### Directory resource guidance + +```markdown +Base directory for this skill: +Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. +``` + +#### URL resource guidance + +```markdown +Base URL for this skill: +Resolve relative URLs mentioned by this skill against the base URL before using them. Load referenced resources only as needed. +``` + +#### Opaque resource guidance + +```markdown +Resources for this skill: +Load referenced resources only as needed. +``` + +### Tool errors + +**What the model sees**: Invalid or stale selections return exactly `Error: invalid skill name ""`, `Error: skill "" is unknown or no longer available`, or `Error: skill "" is not available for model invocation`. Provider-thrown lookup text is data-dependent and receives the same `Error: ` wrapper. + +**Token effect**: Only a failing call adds these retained tokens. + +## Known Limitations and Deferred Work + +- **The catalog omits `whenToUse`, source, and provider metadata** — routing is based only on name and a capped description; `whenToUse` remains provider metadata and is not rendered by the loaded wrapper either. +- **Loaded instruction bodies have no size cap** — a provider can return a skill large enough to consume substantial next-step context; only catalog descriptions are truncated. +- **Resources are guidance, not attachments** — the tool reports a base directory/URL/opaque hint but neither enumerates nor fetches referenced files for the model. +- **Loading is one-shot text** — there is no partial, streaming, or cached-content handle when a remote provider is slow or a skill body is large. diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 329f34a91a..8adf5df07b 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -56,3 +56,25 @@ The child environment is built by [`buildChildEnv`](../subagent-subprocess/READM The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). Keyless tests drive a scripted ACP subprocess over real stdio. The with-key e2e drives the repository's real ACP agent and self-skips without `DEEPSEEK_API_KEY`. + +## Model Experience + +### Child-agent request + +**What the model sees**: The remote child receives the standalone task content through ACP plus its own process's configured system prompt, tools, and fresh session. It receives no parent conversation. This provider advertises no optional start-time capabilities, so the local service rejects requests for persona, tool filtering, depth enforcement, or structured output instead of silently omitting them. + +**Token effect**: The child pays for an independent full context and its own multi-step history. These tokens never enter the parent's context. + +### Parent tool result, indirectly + +**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final streamed assistant text or that consumer's exact stop-reason error, not intermediate messages or tool traffic. A request already cancelled before publication becomes exactly `Error: subagent request was aborted before the ACP child started`; other start failures pass through as `Error: `. + +**Token effect**: Parent input grows only by the final result or error, which is data-dependent and retained until compaction. This provider adds no parent schema itself. + +## Known Limitations and Deferred Work + +- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)). +- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them. +- **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent. +- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut. +- **No snapshot-tier replay coverage** (`TODO(acp-subagent-replay)`) — an ACP child is its own process with its own replay shape, deferred. diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index bf90ecdf52..344b42732d 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -21,3 +21,23 @@ Fork advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, perso | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `fork`). | +See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. + +## Model Experience + +### Child-agent history and envelope + +**What the model sees**: The child receives the parent's balanced completed-turn surface prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited. An optional structured-output request adds its child-only contract. The parent's current in-flight turn is excluded. + +**Token effect**: Forking duplicates retained completed history into separate child requests; the child then accumulates its own tokens independently. Persona changes repeated prompt cost, filtering changes schema or generated SDK cost, and a first-turn fork has no inherited history. + +### Parent tool result, indirectly + +**What the model sees**: The parent receives only the child's own final output through `dsh-tool-subagent`, not the inherited prefix or intermediate work. + +**Token effect**: Parent input grows by one data-dependent final result retained until compaction. + +## Known Limitations and Deferred Work + +- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. +- **The seed is a one-time snapshot** — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing. diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 48fd0a201d..dc10405db7 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -39,3 +39,40 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo - A monotonic tool guard blocks later calls after capture, and `agent/turn-stop` ends the turn after the structured result commits. A clean turn that never commits the required structured value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it. + +## Model Experience + +### Child-agent request + +**What the model sees**: The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. + +**Token effect**: Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance. + +### Structured-output system prompt, schema, and results + +**What the model sees**: A structured run adds the structured-output instruction below. It also adds a child-scoped `structured_output` definition with exact description `Report your final structured result. Call this exactly once, when your answer is complete; the arguments must match this tool's parameter schema exactly.` and the requested schema. This runtime-only definition is outside the generated shipped [tool package map](../../../docs/tool-catalog.md#tool-package-map). Success returns `Structured output recorded.`; a later call becomes ``Error: structured output already recorded: the run is complete, so `` is not executed``. + +**Token effect**: Fixed instruction and capability tokens are paid only by that child. Result text enters the child history, while the captured value alone becomes the parent result. + +#### Structured-output instruction + +```markdown +When you have your final answer, you MUST report it by calling the `structured_output` tool with arguments matching its parameter schema exactly. Do not finish with a plain text answer: only the tool call counts as your result. +``` + +### Parent start error, indirectly + +**What the model sees**: Through `dsh-tool-subagent`, invalid depth state becomes exactly `Error: agent subagentDepth must be a non-negative safe integer`, `Error: subagent child depth exceeds the safe-integer range`, or `Error: subagent depth exceeds maxDepth `. A pre-publication cancellation passes its abort reason through the registry's `Error: ` wrapper. + +**Token effect**: Zero tokens on a successful start; only the failed parent tool call retains this text. + +### Parent result, indirectly + +**What the model sees**: The driver extracts only the child's own last assistant output or captured structured value; seeded parent messages and intermediate child work do not become the result. + +**Token effect**: The parent receives one data-dependent result through the consumer; all other child tokens stay in the child session. + +## Known Limitations and Deferred Work + +- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. +- **Structured capture accepts the `defineTool` schema subset only** — unsupported JSON Schema constructs fail before the child is created; a provider needing a broader schema vocabulary requires a different runtime. diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md index 3c0f739ecf..6f9982f15b 100644 --- a/packages/subagent/subagent-spawn/README.md +++ b/packages/subagent/subagent-spawn/README.md @@ -17,3 +17,22 @@ Spawn advertises `{ outputSchema: true, depthLimit: true, toolFilter: true, pers | Key | Meaning | |---|---| | `providerName` | Registry name on `ctx.subagents` (default `spawn`). | + +## Model Experience + +### Child-agent request + +**What the model sees**: The fresh child receives the standalone task content verbatim, inherits the parent model and workspace by default, and sees the global prompt with any configured child-scoped persona shadow. A tool filter removes global wire schemas, executable lookup, and Code Mode SDK bindings for that child but leaves independently registered guidance. It receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent. + +**Token effect**: The child pays for a new independent context and history; no parent-history tokens are duplicated. Persona changes this child's repeated prompt cost, while filtering changes its schema or generated SDK cost. + +### Parent tool result, indirectly + +**What the model sees**: Through `dsh-tool-subagent`, the parent receives only the child's final output or stop-reason error. + +**Token effect**: Parent input grows by one data-dependent result retained until compaction. + +## Known Limitations and Deferred Work + +- **Runs expose no `sendMessage`/`resume`** — the optional runtime capabilities are absent on in-process runs. +- **Fresh means no parent transcript** — the child inherits cwd, lineage, model, and explicitly configured persona/tool restrictions, but none of the parent's conversation; use the fork provider when completed-turn context is required. diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index ccc68bf31b..a3c0905422 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -38,3 +38,14 @@ A per-run isolated config directory for an external CLI child (the target of `CL ## Testing `tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and the dispose ladder run against a scriptable fake child, driving each escalation tier deterministically. The [ACP backend suite](../subagent-acp/README.md) exercises the same ladder against real subprocesses (EOF-cooperative, EOF-ignoring, and SIGTERM-trapping children) end to end. + +## Model Experience + +Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. + +## Known Limitations and Deferred Work + +- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. +- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder. +- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal. +- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state. diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 413bbbdafc..261effb450 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -59,3 +59,12 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen ## Collection model The current model-facing tool collects synchronously: it awaits the child result and disposes the run before returning. Background collection and polling remain outside this seam. See the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md) and `src/types.ts` for the complete contracts. + +## Model Experience + +Indirectly, through `dsh-tool-subagent`, which retains only a provider's data-dependent final output or exact `Error: no subagent provider registered for ""`, `Error: subagent provider "" does not support the "" capability`, and `Error: ` start failures in the parent while child working tokens remain child-only. + +## Known Limitations and Deferred Work + +- **The current consumer collects synchronously** — the model-facing tool starts a run and awaits `result`; steering (`sendMessage`) is part of the seam but intentionally unused, and background/poll/spill semantics are deferred to a future long-running-runtime design. +- **The lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision surface is deferred until a consumer needs one. diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index c234d5832b..e981788275 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -26,3 +26,41 @@ A non-`completed` stop reason becomes an `isError` tool result; partial child ou | `maxDepth` | Absolute delegation-depth cap; requires provider `depthLimit` capability. | `toolFilter` changes the child's visible global tool layer; it is 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). + +## Model Experience + +### Standalone-provider schema + +**What the model sees**: While a fresh-context provider exists, the configured tool uses the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent); the catalog also records how `toolName` changes the visible name. + +**Token effect**: Fixed schema cost per parent request while mounted. Removing the provider removes the whole schema. + +### Inherited-context-provider schema + +**What the model sees**: Relative to the generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent), a provider that seeds completed turns replaces only the tool and `prompt` parameter descriptions with the text below; the shape and `description` parameter stay unchanged. + +**Token effect**: Fixed schema cost per parent request while mounted. Exposing multiple providers adds one independently named schema per load. + +#### Inherited-context-provider tool description + +```markdown +Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. +``` + +#### Inherited-context-provider prompt description + +```markdown +The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. +``` + +### Tool-call history and result + +**What the model sees**: The task description and full prompt remain in the parent assistant tool call. Success contains only the child's data-dependent final text. Other stop reasons become exactly `Error: subagent run was cancelled`, `Error: subagent run failed`, `Error: subagent run hit its token limit before finishing`, `Error: subagent declined the task`, or `Error: subagent run ended abnormally ()`; a call without an owning agent becomes `Error: subagent tool requires a calling agent (exec.agent was undefined)`. Intermediate child steps never enter the parent. + +**Token effect**: Prompt and final output are data-dependent retained tokens. All child working context is paid in the child and omitted from the parent. + +## Known Limitations and Deferred Work + +- **Delegation blocks the parent turn** — synchronous collect only; background start and poll collection are deferred to the long-running-runtime redesign. +- **Duplicate `toolName` across waiting loads is detected late** (`TODO(subagent-dup-toolname)`) — two loads waiting on providers collide only when a provider arrives, and the throw rolls back the provider's fiber rather than the misconfigured tool's; config-time detection needs a cross-fiber registry of intended names. +- **Child policy is fixed per tool registration** — `model`, persona, tool filter, and depth cap come from this plugin load's config, not model-call arguments; exposing another policy requires another distinctly named tool. diff --git a/packages/support/README.md b/packages/support/README.md index c8883a89ff..b6559a1a79 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,8 +5,8 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| | `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | -| `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) | +| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index f025b53196..7835deb432 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -39,4 +39,13 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove 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). -`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 in the transcript. Missing permission answers cancel; selecting an unavailable kind fails the scenario. +`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript. + +## Model Experience + +None, as this test-only harness records, normalizes, and compares ACP transcripts without changing the agent's assembled model request. + +## Known Limitations and Deferred Work + +- **Session harvest is JSONL-only** — `runScenario` collects persisted `.jsonl` logs, so an example composed over the SQLite persistence backend has no snapshot path. +- **The subprocess boots the unbuilt tsx/Loader path only** — the built-bin artifact is guarded by the separate `built-bin` e2e smokes, never by this tier. diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index fe8ff87d7a..54fb26b333 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -1,8 +1,8 @@ # dsh-invariants -Dev-mode event-contract assertions. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests at runtime; it does not own or change product behavior. +Runtime event-contract assertions intended for development diagnostics. This pure-listener plugin checks relationships among session events, agent states, scoped dispatches, and model requests; it does not own or change product behavior. -**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract. +The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-core`](../../core/agent-core/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract. Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. @@ -45,8 +45,17 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`). ## Why runtime assertions remain useful -Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships in development while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). +Session enforces the per-record storage boundary at runtime, where a cast cannot bypass it. Pervasive `DeepReadonly` types would add noise across consumers without expressing relationships such as turn/step nesting, subject-correct scoped dispatch, or equality between a request and its log reconstruction. This plugin checks those relationships wherever it is mounted while `dsh-session` keeps history immutable in every composition. See [source-owned session immutability and dev-mode invariants](../../../docs/rfc/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md). ## Seeded sessions A seeded or forked session arrives with events already in its log because construction does not emit `session/event` for each seed record. `Session` validates, snapshots, and freezes every seed record before accepting it; on `session/created`, this plugin replays the accepted log only to rebuild and check its relational trace state. + +## Model Experience + +None, as this observer only validates events and frozen requests and never rewrites prompts, schemas, messages, or streams. + +## Known Limitations and Deferred Work + +- **The request-reconstructability assertion covers loop-built requests only** — hand-built one-shots (e.g. compaction's summarize call) carry no live `sessionId` marker and are skipped. +- **Merge-extended event families get no family-specific assertions** — `compact/*` lock pairing and `hook/*` invoked/result pairing are not checked here; only the core turn/step/chunk/tool-result contract is. diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 588c658857..3568465d18 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-invariants", - "description": "Dev-mode event-contract assertions for the DeepSeek Harness", + "description": "Runtime event-contract assertions for DeepSeek Harness development diagnostics", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 547319c8b9..f5b7828a3d 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,8 +1,9 @@ /** - * Dev-only listeners for relationships that event types and immutability cannot express: turn and - * step nesting, scoped dispatch, status transitions, and request reconstruction. Enable in tests - * and demos, not production. Sessions already snapshot and freeze individual events; this plugin - * checks the cross-event contract and serves as its executable documentation. + * Runtime listeners that fail loudly when cross-event contracts are broken: + * turn and step nesting, scoped dispatch, status transitions, and request + * reconstruction. The plugin has no environment guard and is active wherever + * mounted, including the default `dsh-agent-core` bundle; custom compositions + * may omit it. Sessions still own event snapshots and freezing. * @module @deepseek-ai/dsh-invariants */ @@ -329,7 +330,7 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { } /** - * Register the dev-mode invariants. Contributions are effect-scoped, so + * Register the runtime invariants. Contributions are effect-scoped, so * disposing the plugin fiber removes all listeners (HMR-safe). On (re-)apply * the trace state is rebuilt by replaying each existing session's log, so a * hot reload mid-turn does not falsely reject the next event. diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 78b411d54c..847064b160 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -43,3 +43,12 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s ## Plugin export shape Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). + +## Model Experience + +None, as this keyless test adapter sends no request to a provider model; it only replays recorded assistant chunks into the test loop. + +## Known Limitations and Deferred Work + +- **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently (or a compaction summarize call landing mid-run) would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). +- **Only chunk-producing calls are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar; the override replaces the PRIMARY session's script only. diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md index b8d9fbba44..a879433b67 100644 --- a/packages/support/subagent-mock/README.md +++ b/packages/support/subagent-mock/README.md @@ -18,3 +18,12 @@ Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no defa | `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | Aborting the required request signal or disposing before `result` settles flips the stop reason to `aborted`, so both holder-facing cancellation paths are observable. + +## Model Experience + +Indirectly, through `dsh-tool-subagent`, which renders this test provider's configured reply or stop-reason error into the parent test history. + +## Known Limitations and Deferred Work + +- **Scripted provider only** — it does not run a model, create a child agent, or exercise real prompt/tool-loop behavior. +- **One synthetic outcome per run** — it models no multi-turn, streaming, steering, resume, or subprocess transport behavior. diff --git a/packages/timeout/timeout-policy/README.md b/packages/timeout/timeout-policy/README.md index e637a658bf..474185a4eb 100644 --- a/packages/timeout/timeout-policy/README.md +++ b/packages/timeout/timeout-policy/README.md @@ -32,3 +32,16 @@ The derived signal only **notifies**; termination stays with the tool and the ca ### Composing with other `tools/execute` wrappers Multiple `tools/execute` listeners compose by cordis registration order. Combined with a future retry/sandbox/metrics wrapper, registration order chooses the semantics — "timeout covers the whole retry operation" (timeout registered outer) versus "timeout covers each attempt" (timeout registered inner). + +## Model Experience + +### Conditional tool result + +**What the model sees**: This plugin adds no prompt or schema. If a declared deadline wins, it replaces the provider's outcome with `Error: tool call timed out after ms` plus structured `TOOL_TIMEOUT`; otherwise the original result passes through unchanged. + +**Token effect**: Zero tokens on non-timeout calls. A timeout adds one small retained error result and can prevent a larger late provider result from entering context. + +## Known Limitations and Deferred Work + +- **Cooperative, never a hard kill** — the deadline only notifies via `exec.signal`; a tool that ignores the signal does not stop on timeout (see § Cooperative, not a hard kill). +- **No blanket budget** — only tools that declare `timeoutMs` on their `ToolDefinition` get a deadline; there is no registry-wide default for undeclared tools (the shipped `bash`/`read`/`write`/`edit` deliberately declare none). diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index b27cc65227..5736d23e85 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -23,3 +23,23 @@ The tool writes only the session event; it does not render. UIs subscribe to `se ## Export shape A function/namespace plugin: it exports `name` / `inject` / `apply` and NO default. A stray `export default` would collapse the module via the Loader's `unwrapExports` and drop `inject` (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). + +## Model Experience + +### Tool schema + +**What the model sees**: The model sees the generated [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo). + +**Token effect**: Fixed schema cost on every request where the tool is visible. + +### Tool-call history and result + +**What the model sees**: Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: pending, in progress, completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content ""`, `Error: invalid todos: at most one task may be in_progress, got `, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message. + +**Token effect**: Token growth scales with every full list the model submits, and those call arguments remain until compaction. The result itself is small and fixed-shape. + +## Known Limitations and Deferred Work + +- **Single-owner scope only** — the list belongs to the one calling agent session; subagent/shared/swarm scopes are a deliberate cut (see § Single owner), and a non-agent caller is rejected. +- **The item shape is deliberately minimal** — `content` plus three-state `status`; no id, priority, or active-form fields, and the ACP bridge synthesizes the `priority` ACP requires. +- **Whole-list replacement is the only operation** — no partial updates, no read-back tool; the model must resend the entire list each call. diff --git a/packages/ui/acp-agent/README.md b/packages/ui/acp-agent/README.md index a9622f1eeb..8730102b64 100644 --- a/packages/ui/acp-agent/README.md +++ b/packages/ui/acp-agent/README.md @@ -15,6 +15,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | +| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer | | ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) | | ~~`hmr`~~ | **omitted** — the editor owns the subprocess | @@ -27,6 +28,8 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron | `model` | (required) | the per-session agent template the bridge creates agents from | | `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` | +| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-core` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay) and a bash executor. @@ -42,3 +45,13 @@ The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers through its internal module loader, active only under that flag. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.) All diagnostics go to **stderr** — stdout is the protocol. + +## Model Experience + +Indirectly, through `dsh-agent-core` and `dsh-acp`, which compose each ACP agent's prompt, tools, and message history; this app bundle adds no model-bound content itself. + +## Known Limitations and Deferred Work + +- **JSONL persistence is baked in** — config chooses its root but cannot select a different backend; that requires a sibling entry or differently composed app package. +- **User-question and approval mechanisms are omitted by default** — the bridge can answer both when their services/tools are composed, but this front door does not enable those deployment policies itself. +- **A leaf can still corrupt stdout** — the app mounts no console logger, but it cannot prevent a sibling leaf entry from writing non-protocol bytes to the ACP channel. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index ea6301f7a1..dd705e8d8d 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -46,7 +46,7 @@ Background bash tasks use the session id as an opaque owner token, so one sessio ## Per-session cwd -`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. +`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported. ## Tool-call presentation @@ -54,7 +54,7 @@ Tools return provider-neutral `generic`, `terminal`, or `diff` render intents fr ## Terminal card (capability-gated) -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). +When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived 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) and [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md). ## Settle-exactly-once @@ -68,10 +68,6 @@ For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `a 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) - -- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. - ## stdout is the protocol The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging. @@ -90,3 +86,37 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa } } ``` + +## Model Experience + +### User messages + +**What the model sees**: Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name= uri=]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted. + +**Token effect**: Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts. + +### Human answers and permission decisions + +**What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. + +**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. + +### Permission preset switches + +**What the model sees**: `session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only. + +**Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome. + +### Loaded sessions + +**What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message. + +**Token effect**: Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none. + +## Known Limitations and Deferred Work + +- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented. +- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`. +- **One configured `model` for every created session** — per-session model selection has no config or protocol surface here yet. +- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md). +- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam. diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 92fbc333be..fdea712274 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,3 +13,13 @@ Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md) Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. + +## Model Experience + +Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application. + +## Known Limitations and Deferred Work + +- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals`; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping. +- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. +- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. diff --git a/packages/ui/jsonrpc-agent/README.md b/packages/ui/jsonrpc-agent/README.md index 068530b586..4ced8d038a 100644 --- a/packages/ui/jsonrpc-agent/README.md +++ b/packages/ui/jsonrpc-agent/README.md @@ -15,3 +15,13 @@ stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exit ## stdout is the protocol stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, and the config must omit stdout loggers. + +## Model Experience + +Indirectly, through the plugins loaded from the external `cordis.yml`, which own every model-bound prompt, schema, message, and result; this bin adds none of its own. + +## Known Limitations and Deferred Work + +- **The bin cannot prove that the config serves JSON-RPC** — a valid config with no `dsh-jsonrpc` entry boots successfully and serves nothing. +- **No built-in or default config exists** — every launch must provide `DSH_CORDIS_CONFIG` or a positional path, and deployment owns the complete plugin tree and stdout discipline. +- **stdin EOF cuts off in-flight work** — client disappearance disposes the root immediately; callers that need orderly completion use the protocol-level `shutdown` request. diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 59373e78b9..46c82aa05b 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -21,3 +21,17 @@ A `shutdown` request flushes its response, disposes the plugin fiber, then exits ## Wire notes `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. Each session permits one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and deployment persona remain in `cordis.yml`. + +## Model Experience + +### SDK user message + +**What the model sees**: For each accepted `session/prompt`, the conversation model receives the caller-supplied `contentBlocks` verbatim as one user message in that SDK session. This package adds no system-prompt prose or tool schema; those come from the plugins in the surrounding `cordis.yml`. + +**Token effect**: Data-dependent user-message tokens enter retained session history and are resent on later turns until another package compacts them. The JSON-RPC frames, session notifications, and server bookkeeping add zero model-context tokens. + +## Known Limitations and Deferred Work + +- **The wire has no per-session close or prompt-cancel method** — SDK-created agents remain live until process shutdown, and one accepted prompt runs to agent idle before that session accepts another. +- **stdout purity is deployment-enforced** — a surrounding config can still load a stdout logger and corrupt the JSON-RPC channel; this plugin does not inspect or veto sibling loggers. +- **Automatic adapter mounting is DeepSeek-specific** — `initialize` can reuse any pre-registered model adapter, but its only fallback mounts `dsh-llm-deepseek`. diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 3ebda4ed32..bd37890dd1 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -4,4 +4,14 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See [the acp-agent example](../../../examples/acp-agent/) for the composition and [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) for the switching design. +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). + +## Model Experience + +Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. + +## Known Limitations and Deferred Work + +- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. +- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. +- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..8cd97f5cc7 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -26,8 +26,10 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | -| `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | +| `persona` | — | the deployment persona template (may reference `{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | +| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` | +| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-core` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | @@ -63,3 +65,23 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s ``` Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". + +## Model Experience + +### Composed terminal agent request + +**What the model sees**: Through `dsh-agent-core`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message. + +**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens. + +### Human-answer result + +**What the model sees**: Through `dsh-tool-ask-user`, successful terminal answers use that package's exact compact JSON shape. Interruption becomes exactly `Error: ask_user_question was interrupted before the user answered`; a closed stdin becomes `Error: ask_user_question cannot be answered because stdin is closed`. + +**Token effect**: Only a completed or failed tool call adds retained result tokens; prompts printed while waiting are terminal-only. + +## Known Limitations and Deferred Work + +- **One pre-created `main` agent drives the readline UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. +- **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. +- **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 10d4a082ba..880b56ecc5 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -18,3 +18,22 @@ The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "a ## Role This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop. + +## Model Experience + +### Tool schema + +**What the model sees**: The model sees the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user), including question ids, prompts, headings, options, and multi-select flags. + +**Token effect**: Fixed schema cost on every request where the tool is visible. + +### Tool-call history and result + +**What the model sees**: The model's full questions remain in the assistant tool-call arguments. After the human answers, the next step sees compact JSON in the exact shape `{"answers":[{"id":"","selected":["