Merge origin/master (generic task runtime #219, single-exe closure, package renames)
Semantic resolutions beyond line merges: - The bash seam keeps resolveMode + the bash/resolve-mode waterfall on master's task-free BashExecutor (run/start/resolve only; BashProcess handles); tool-bash consults it at its stamping site and escalation baseline on master's render/background split, with a waterfall test on the recording executor. - dsh-mode's BASH_FAMILY narrows to ['bash']: bash_output/bash_kill are replaced by the kind-generic task_output/task_kill, which span every task kind and only observe or stop work, so the access cap withholds only the starter it can reason about. - The plan-mode snapshot suite adopts master's pin grammar (tool-schema sidecars; the expectedHeaderSnapshots extension is gone — the exit transition deltas, and entering-before-turn-1 needs no second snapshot); modes-advertise joins the plan header class (no-model, so membership is vacuous). Fixtures re-recorded on the acp-demo bin; the replay overlay gains the passthrough sandbox runner. - examples/plan-acp-agent rewires to @deepseek-ai/dsh-acp-demo and drops its tool-bash entry (the spine bundle now composes it); dsh-stdio (the renamed stdio-chat home) keeps its /mode command and gains the dsh-mode peer edge; the acp bridge keeps the modes surface beside master's permission presets. - mode README gains the Model Experience / Known Limitations sections the new README gates require; AGENTS.md ceiling 1370 → 1440 for the kept mode/ layout line and Agent efficiency section.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# AGENTS.md — The documentation standard
|
||||
|
||||
This file is the contract for every Markdown files in the repo: each tier's job, the writing rules, and the word budgets that `verify-doc-budgets` enforces. The audit/apply workflow is the [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) skill; the decision record is [the doc-tiers-and-budgets RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md).
|
||||
This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
|
||||
|
||||
## The tier taxonomy: one home per fact
|
||||
|
||||
Every fact has exactly one home — the tier whose job it is — and every other place that needs it links there instead of restating it. A rule restated in two files drifts word-by-word until the copies disagree; a link cannot drift, and `verify-md-links` keeps it resolving.
|
||||
Each fact has one home: the tier whose job it is. Elsewhere, link to that home; `verify-md-links` keeps links resolving while duplicated prose drifts.
|
||||
|
||||
| Tier | Job | Does NOT belong there |
|
||||
|---|---|---|
|
||||
@@ -12,30 +12,30 @@ Every fact has exactly one home — the tier whose job it is — and every other
|
||||
| Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`) | Orders specific to that subtree | Repo-wide rules the root file already carries |
|
||||
| [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ RFCs), implementation-status annotations |
|
||||
| [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) |
|
||||
| [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped |
|
||||
| [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) |
|
||||
| Skills (`.agents/skills/`) | Reusable workflows and specialized decision standards | Product and runtime contracts (→ docs or source) |
|
||||
|
||||
Placement test: a story about a bug → postmortem. Why we chose X → RFC. How to do task Y → cookbook. What type Z looks like → core-data-structures. What package P promises → its README. A rule every agent must always obey → root AGENTS.md, one line, linking the home that holds the why.
|
||||
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
|
||||
|
||||
- **Document the current state — never the process or history that produced it.** Prose describes what the code IS and why, as if it had always been so: no "previously/now/no longer/used to/renamed/moved here", and never name a change unit the reader cannot see — a PR, commit, or stack position — in comments, JSDoc, or test names; name the mechanism instead. A genuinely clarifying contrast is framed against the live alternative as a standing fact, not against the past. The change story belongs in the commit message, the PR description, or an RFC.
|
||||
- **A decision worth re-litigating gets an RFC in the same PR.** The test: would a maintainer six months out ask "why was it done this way?" and find no answer in the code? If yes, write one ([when to write one](rfc/README.md)); mechanical or self-evident changes need none.
|
||||
- **One physical line per paragraph** (`verify-md-wrap`): the editor soft-wraps; hard breaks make a one-word edit re-diff the whole paragraph. Prose only — code blocks, tables, and list structure stay; code comments stay under the linter's column limit.
|
||||
- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems.
|
||||
- **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)).
|
||||
- **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit.
|
||||
- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)).
|
||||
- **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.
|
||||
- Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams".
|
||||
|
||||
## Wordcount Budgets
|
||||
|
||||
Every PR has a lesson it wants to append, and without pressure nothing leaves. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) stores the allowed word-count ceiling for each budgeted standing doc; `pnpm run verify-doc-budgets` fails when a doc exceeds its ceiling or a budgeted file is missing.
|
||||
[scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) sets standing-doc ceilings; `pnpm run verify-doc-budgets` rejects excess or missing files.
|
||||
|
||||
When the gate goes red:
|
||||
|
||||
@@ -43,7 +43,7 @@ When the gate goes red:
|
||||
2. **Condense** content that belongs here but can be shorter.
|
||||
3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug.
|
||||
|
||||
Ceilings keep working headroom: at least 5% above the current size, ratcheted down after trims. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling; review and the slop checklist govern them.
|
||||
Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers.
|
||||
|
||||
## The slop checklist
|
||||
|
||||
@@ -54,12 +54,15 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar
|
||||
- A war story told inline where a one-line rule plus a postmortem/RFC link would do.
|
||||
- Implementation-status annotations in prose or diagrams ("implemented!", "future: …"). Status rots; the repo layout and package manifests carry it.
|
||||
- Hand-restating a generated catalog or JSDoc: event tables, tool arg tables, method signatures. Link instead.
|
||||
- Hand-maintained inventories of tests, packages, or implementation status when the tree or a generator is authoritative.
|
||||
- Reasoning transcripts: step-by-step implementation narration, proof of obvious branches, test walkthroughs, or rejected local alternatives. Keep the resulting contract or durable rationale; delete the path used to derive it.
|
||||
- The same rationale repeated beside sibling methods. State it once at the owning seam or shared helper.
|
||||
- Paragraph walls: one paragraph carrying several rules and parenthetical asides. Split it, or demote the detail to the linked home.
|
||||
- Emphasis inflation: bold, CAPS, or "critically" everywhere means nothing stands out. Reserve emphasis for the clause that changes behavior.
|
||||
- Spec-speak in `implemented/` RFCs: "should", migration plans, acceptance checklists. An implemented RFC describes what is, per [rfc/implemented/AGENTS.md](rfc/implemented/AGENTS.md).
|
||||
|
||||
## Cross-reference with machine-checkable links, never free prose
|
||||
|
||||
When one doc refers to another doc, an RFC, a package README, or any file in the repo, link it with a relative Markdown link to the actual path — never bare prose or a number ("see RFC 005"), which is uncheckable and rots on rename. `pnpm run verify-md-links` (part of `doc-sync`; see [the cross-link lint RFC](rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md)) fails when a relative target does not exist, so a rename that orphans a link is caught before review. This is also why RFC files carry dates and topics instead of stable numbers: they survive moves between lifecycle and class folders without dangling references.
|
||||
Link repository references with relative Markdown paths, never bare filenames or RFC numbers. `verify-md-links` catches missing targets; the [cross-link RFC](rfc/implemented/process/2026-06-18-markdown-cross-link-lint.md) owns the rationale.
|
||||
|
||||
The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one.
|
||||
|
||||
@@ -33,8 +33,10 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
|
||||
| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction |
|
||||
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
|
||||
| `ctx.modes` | [`mode/`](../packages/mode/README.md) | logged session modes (plan mode) |
|
||||
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
|
||||
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
|
||||
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
|
||||
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads |
|
||||
|
||||
## Event
|
||||
|
||||
@@ -94,7 +96,7 @@ forever:
|
||||
checkpoint persistence and notify idle/running status
|
||||
```
|
||||
|
||||
Prompt assembly is single-path: the loop sends `renderPrompt(await assemble(assembleContextFor(agent)))`; the helper couples the explicit agent and scope. Plugins contribute ordered sections, tool schemas, and named variables interpolated as `{{name}}` at render — strictly, so an unknown or valueless reference fails the turn instead of shipping a hole. `dsh-system-prompt` owns the openers — the static `harness:identity` section (order −100) and the deployment's global default persona (order 0, shadowable by a same-named agent-scoped section) — while the loop registers the `model`/`cwd` variables; prompt-fact ownership is pinned by the [prompt-variables RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
|
||||
The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
|
||||
|
||||
Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved.
|
||||
|
||||
@@ -106,11 +108,11 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
|
||||
|
||||
### Agent Handles
|
||||
|
||||
`ctx.agents` owns live agents and returns an `AgentHandle { agent, dispose() }`. `Agent` is the API other plugins drive: `send()` queues work, `steer()` injects mid-turn content, `inject()` appends context and opens a one-shot injection turn when idle, `cancel()` is the public stop primitive, and `whenIdle()` observes quiescence. The caller fiber and concrete factory provider structurally co-own programmatic lifecycles; a consumer handle is the only non-structural teardown capability, and every owner reaches the same awaited disposer.
|
||||
`ctx.agents` owns live agents and returns `AgentHandle { agent, dispose() }`. Plugins drive `Agent` through `send()`, `steer()`, `inject()`, `cancel()`, and `whenIdle()`. The caller fiber and factory provider structurally co-own programmatic lifecycles; the consumer handle is the only other teardown capability, and all owners await one disposer.
|
||||
|
||||
### Agent Scope
|
||||
|
||||
Every live agent owns `agent.ctx` ([`dsh-scope`](../packages/core/scope/README.md), keyed by the agent). Its registrations are visible only to that agent, shadow same-named globals, and unwind with it. Its listeners hear only that agent's dispatches; an opaque carrier routes while the real subject stays explicit. `CreateAgentOptions.setup(agentCtx)` composes this world before publication and does not drive. Dev invariants and `verify-scoped-dispatch` keep carrier/subject identity aligned with event declarations. Rationale: [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent `persona`, `toolFilter`, and `maxDepth` are the separate [composition-controls feature](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
|
||||
Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
|
||||
|
||||
## State
|
||||
|
||||
@@ -138,7 +140,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca
|
||||
|
||||
### Bundles And Apps
|
||||
|
||||
`dsh-agent-core` is the default composition bundle: one plugin loading the shared spine ([README](../packages/core/agent-core/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-agent` for terminal REPL, and `dsh-acp-agent` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
|
||||
`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
|
||||
|
||||
### Where New Behavior Goes
|
||||
|
||||
@@ -149,6 +151,7 @@ New behavior should attach to a documented extension point; changing the shipped
|
||||
| Add a model provider | register an adapter on `ctx.llm` |
|
||||
| Add a model-facing capability | register a tool on `ctx.tools`; schemas flow into prompt assembly |
|
||||
| Add command execution | implement and register a `ctx.bash` backend |
|
||||
| Add a long-running/background capability | register the work on `ctx.tasks`; the generic `task_*` tools collect/stop it |
|
||||
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
|
||||
| Confine spawned processes | a `ctx.sandbox` backend; consumers wrap their argv before spawning |
|
||||
| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall; use serial `agent/turn-stop` for a monotonic terminal stop |
|
||||
|
||||
@@ -18,12 +18,14 @@ flowchart LR
|
||||
svc_sessions["ctx.sessions<br/>In-memory session store"]
|
||||
pkg_agent["agent"]
|
||||
pkg_session_persistence["session-persistence"]
|
||||
pkg_session_query["session-query"]
|
||||
pkg_subagent_inprocess["subagent-inprocess"]
|
||||
pkg_invariants["invariants"]
|
||||
svc_sessionPersistence["ctx.sessionPersistence<br/>Durable session persistence seam"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
pkg_acp["acp"]
|
||||
svc_sessionQuery["ctx.sessionQuery<br/>Exact session-history reads"]
|
||||
pkg_system_prompt["system-prompt"]
|
||||
svc_systemPrompt["ctx.systemPrompt<br/>System prompt assembly registry"]
|
||||
pkg_tools["tools"]
|
||||
@@ -38,15 +40,16 @@ flowchart LR
|
||||
pkg_tool_todo["tool-todo"]
|
||||
pkg_user_interaction["user-interaction"]
|
||||
svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
pkg_stdio_demo["stdio-demo"]
|
||||
pkg_mode["mode"]
|
||||
svc_modes["ctx.modes<br/>Session-mode policy state"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
pkg_skill["skill"]
|
||||
svc_skills["ctx.skills<br/>Skill provider registry"]
|
||||
pkg_skill_local["skill-local"]
|
||||
svc_agents["ctx.agents<br/>Agent registry"]
|
||||
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
|
||||
pkg_agent_core["agent-core"]
|
||||
pkg_agent_spine_demo["agent-spine-demo"]
|
||||
pkg_bash["bash"]
|
||||
svc_bash["ctx.bash<br/>Bash executor seam"]
|
||||
pkg_bash_local["bash-local"]
|
||||
@@ -58,6 +61,8 @@ flowchart LR
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
pkg_approval["approval"]
|
||||
svc_approval["ctx.approval<br/>Approval seam"]
|
||||
pkg_permission["permission"]
|
||||
svc_permission["ctx.permission<br/>Permission presets"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
svc_codeRuntime["ctx.codeRuntime<br/>Code-execution seam"]
|
||||
pkg_code_runtime_worker["code-runtime-worker"]
|
||||
@@ -73,6 +78,9 @@ flowchart LR
|
||||
pkg_subagent_fork["subagent-fork"]
|
||||
pkg_subagent_acp["subagent-acp"]
|
||||
pkg_subagent_mock["subagent-mock"]
|
||||
pkg_tasks["tasks"]
|
||||
svc_tasks["ctx.tasks<br/>Background task registry"]
|
||||
pkg_tool_tasks["tool-tasks"]
|
||||
pkg_web["web"]
|
||||
svc_web["ctx.web<br/>Web access provider registry"]
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
@@ -102,21 +110,24 @@ flowchart LR
|
||||
pkg_llm_pi_ai --> svc_llm
|
||||
pkg_llm_replay --> svc_llm
|
||||
pkg_mode --> svc_modes
|
||||
pkg_permission --> svc_permission
|
||||
pkg_sandbox --> svc_sandbox
|
||||
pkg_sandbox_local --> svc_sandbox
|
||||
pkg_session --> svc_sessions
|
||||
pkg_session_persistence --> svc_sessionPersistence
|
||||
pkg_session_persistence_jsonl --> svc_sessionPersistence
|
||||
pkg_session_persistence_sqlite --> svc_sessionPersistence
|
||||
pkg_session_query --> svc_sessionQuery
|
||||
pkg_skill --> svc_skills
|
||||
pkg_skill_local --> svc_skills
|
||||
pkg_stdio_agent --> svc_userInteraction
|
||||
pkg_stdio_demo --> svc_userInteraction
|
||||
pkg_subagent --> svc_subagents
|
||||
pkg_subagent_acp --> svc_subagents
|
||||
pkg_subagent_fork --> svc_subagents
|
||||
pkg_subagent_mock --> svc_subagents
|
||||
pkg_subagent_spawn --> svc_subagents
|
||||
pkg_system_prompt --> svc_systemPrompt
|
||||
pkg_tasks --> svc_tasks
|
||||
pkg_tools --> svc_tools
|
||||
pkg_user_interaction --> svc_userInteraction
|
||||
pkg_web --> svc_web
|
||||
@@ -126,11 +137,11 @@ flowchart LR
|
||||
pkg_web_search_perplexity --> svc_web
|
||||
pkg_workflow --> svc_workflows
|
||||
pkg_workflow_workerthread --> svc_workflows
|
||||
svc_agentLoop --> pkg_agent_core
|
||||
svc_agentLoop --> pkg_agent_spine_demo
|
||||
svc_agents --> pkg_acp
|
||||
svc_agents --> pkg_agent_loop
|
||||
svc_agents --> pkg_invariants
|
||||
svc_agents --> pkg_stdio_agent
|
||||
svc_agents --> pkg_stdio_demo
|
||||
svc_agents --> pkg_subagent_inprocess
|
||||
svc_approval --> pkg_tool_bash
|
||||
svc_approval --> pkg_tools
|
||||
@@ -144,13 +155,16 @@ flowchart LR
|
||||
svc_llm --> pkg_compact_basic
|
||||
svc_modes --> pkg_acp
|
||||
svc_modes --> pkg_stdio_agent
|
||||
svc_permission --> pkg_acp
|
||||
svc_sandbox --> pkg_bash_sandbox
|
||||
svc_sessionPersistence --> pkg_acp
|
||||
svc_sessionPersistence --> pkg_agent_loop
|
||||
svc_sessionPersistence --> pkg_session_query
|
||||
svc_sessions --> pkg_agent
|
||||
svc_sessions --> pkg_agent_loop
|
||||
svc_sessions --> pkg_invariants
|
||||
svc_sessions --> pkg_session_persistence
|
||||
svc_sessions --> pkg_session_query
|
||||
svc_sessions --> pkg_subagent_inprocess
|
||||
svc_skills --> pkg_tool_skill
|
||||
svc_subagents --> pkg_tool_subagent
|
||||
@@ -158,6 +172,9 @@ flowchart LR
|
||||
svc_systemPrompt --> pkg_tool_fs
|
||||
svc_systemPrompt --> pkg_tool_web
|
||||
svc_systemPrompt --> pkg_tools
|
||||
svc_tasks --> pkg_tool_bash
|
||||
svc_tasks --> pkg_tool_subagent
|
||||
svc_tasks --> pkg_tool_tasks
|
||||
svc_tools --> pkg_acp
|
||||
svc_tools --> pkg_agent_loop
|
||||
svc_tools --> pkg_tool_ask_user
|
||||
@@ -169,7 +186,7 @@ flowchart LR
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_userInteraction --> pkg_acp
|
||||
svc_userInteraction --> pkg_stdio_agent
|
||||
svc_userInteraction --> pkg_stdio_demo
|
||||
svc_userInteraction --> pkg_tool_ask_user
|
||||
svc_web --> pkg_tool_web
|
||||
svc_workflows --> pkg_tool_workflow
|
||||
@@ -179,22 +196,25 @@ flowchart LR
|
||||
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
|
||||
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
|
||||
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
|
||||
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. |
|
||||
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
|
||||
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
|
||||
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
|
||||
| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
|
||||
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
|
||||
| `ctx.modes` | `core` | [`mode`](../packages/mode/mode) | - | `stdio-agent`, [`acp`](../packages/ui/acp) | - | Folds the logged per-agent mode (mode/set), flushes user flips at turn boundaries, and enforces the mode through the assemble filter and the tools/pre-execute gate. |
|
||||
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
|
||||
| `ctx.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. |
|
||||
| `ctx.approval` | `seam` | `approval` | [`acp`](../packages/ui/acp) | [`tools`](../packages/core/tools), [`tool-bash`](../packages/bash/tool-bash) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. |
|
||||
| `ctx.permission` | `core` | [`permission`](../packages/ui/permission) | - | [`acp`](../packages/ui/acp) | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. |
|
||||
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). |
|
||||
| `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. |
|
||||
| `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. |
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. |
|
||||
| `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
|
||||
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow) | - | One engine per context (bash shape, no named-provider registry); the worker-thread engine fans agent() calls out through ctx.subagents. |
|
||||
|
||||
|
||||
@@ -18,22 +18,16 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte
|
||||
export interface AcpConfig {
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
model?: string
|
||||
/**
|
||||
* Transport stream override. Production omits this (the plugin wires
|
||||
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
|
||||
* in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
|
||||
* the bridge without a subprocess. Not part of the schemastery `Config` —
|
||||
* it is a runtime-only seam, never set from a `cordis.yml`.
|
||||
*/
|
||||
/** Runtime-only transport override for tests; production uses stdio. */
|
||||
stream?: Stream
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: `Stream` (`@agentclientprotocol/sdk`)
|
||||
|
||||
Source: [`packages/ui/acp/src/index.ts:253`](../packages/ui/acp/src/index.ts)
|
||||
Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-acp-agent`
|
||||
## `@deepseek-ai/dsh-acp-demo`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
@@ -43,7 +37,7 @@ Source: [`packages/ui/acp/src/index.ts:253`](../packages/ui/acp/src/index.ts)
|
||||
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
||||
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
||||
* through agent-core); `persistenceRoot` is the JSONL backend's directory.
|
||||
* through agent-spine-demo); `persistenceRoot` is the JSONL backend's directory.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||
@@ -52,61 +46,22 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-core. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
|
||||
Source: [`packages/ui/acp-agent/src/index.ts:52`](../packages/ui/acp-agent/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-core`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `skills` to the skill registry/local provider/tool consumer. Every field
|
||||
* is optional INPUT here because each owner's schema supplies the default;
|
||||
* the schema is the INTERSECTION of the owners' own schemas (with registry
|
||||
* schemas nested under their bundle keys), so validation and defaulting can
|
||||
* never drift from them.
|
||||
*/
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level discovery cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
/** Model-facing skill catalog and tool settings. */
|
||||
tool?: toolSkill.Config
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-core/src/index.ts:87`](../packages/core/agent-core/src/index.ts)
|
||||
Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
@@ -129,7 +84,57 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Bundle config: each field forwarded verbatim to the child that owns it —
|
||||
* `agents` to the agent loop (an app that pre-creates no agents, like the ACP
|
||||
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
|
||||
* plugin (the deployment's persona section and the explicit model-facing tool
|
||||
* order), the `tools` object to the tool registry (its presentation `mode`),
|
||||
* and `toolBash`/`toolTasks` to the two model-facing tool plugins this bundle
|
||||
* owns. Producer opt-in stays producer-local: `toolBash` configures bash only;
|
||||
* future background-capable tools remain independently composed plugins.
|
||||
* Every field is optional INPUT here because each owner's schema
|
||||
* supplies the default (`[]` / `''` / absent — lexicographic / `native`); the
|
||||
* schema is the INTERSECTION of the owners' own schemas (the registry's
|
||||
* nested under its `tools` key), so validation and defaulting can never
|
||||
* drift from them.
|
||||
*/
|
||||
export interface Config {
|
||||
/** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */
|
||||
agents?: AgentLoopConfig['agents']
|
||||
/** The deployment persona (see dsh-system-prompt's `Config`). */
|
||||
persona?: SystemPromptConfig['persona']
|
||||
/** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */
|
||||
toolOrder?: SystemPromptConfig['toolOrder']
|
||||
/** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */
|
||||
tools?: ToolsConfig
|
||||
/** Skill registry, local provider, and model-facing consumer config. */
|
||||
skills?: SkillConfig
|
||||
/** Model-facing bash tool config, including this producer's background opt-in. */
|
||||
toolBash?: toolBash.Config
|
||||
/** Generic background-task control-tool wait bounds. */
|
||||
toolTasks?: toolTasks.Config
|
||||
}
|
||||
|
||||
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level discovery cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
/** Model-facing skill catalog and tool settings. */
|
||||
tool?: toolSkill.Config
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts)
|
||||
|
||||
Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
@@ -149,7 +154,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts)
|
||||
Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-sandbox`
|
||||
|
||||
@@ -160,7 +165,7 @@ Requires: `sandbox`
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
|
||||
* fail-safe default; an example that wants a workspace-writable agent opts in
|
||||
* explicitly). The runner choice is NOT configured here: which platform
|
||||
* explicitly). The runner choice is not configured here: which platform
|
||||
* backend confines the command is the `ctx.sandbox` provider's config.
|
||||
*/
|
||||
export interface Config extends LocalConfig {
|
||||
@@ -176,7 +181,7 @@ export interface Config extends LocalConfig {
|
||||
|
||||
Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md)
|
||||
|
||||
Source: [`packages/bash/bash-sandbox/src/index.ts:60`](../packages/bash/bash-sandbox/src/index.ts)
|
||||
Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-code-runtime-worker`
|
||||
|
||||
@@ -212,7 +217,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:30`](../packages/code-runtime/code-runtime-worker/src/index.ts)
|
||||
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../packages/code-runtime/code-runtime-worker/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-compact-basic`
|
||||
|
||||
@@ -265,7 +270,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/fs-local/src/index.ts:58`](../packages/fs/fs-local/src/index.ts)
|
||||
Source: [`packages/fs/fs-local/src/index.ts:35`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-hooks-claude`
|
||||
|
||||
@@ -276,7 +281,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.
|
||||
@@ -301,7 +306,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../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`
|
||||
|
||||
@@ -311,7 +316,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.
|
||||
@@ -326,43 +331,27 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hooks-codex/src/index.ts:47`](../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`
|
||||
|
||||
Requires: `agents`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config. Every field is a runtime-only test seam — none is part of the
|
||||
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
|
||||
* (production always serves the process stdio and exits via `process.exit`).
|
||||
*/
|
||||
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
|
||||
export interface JsonRpcConfig {
|
||||
/**
|
||||
* Transport input override. Production omits this (the plugin reads
|
||||
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
|
||||
* without a subprocess.
|
||||
*/
|
||||
/** Transport input override; production uses `process.stdin`. */
|
||||
input?: Readable
|
||||
/**
|
||||
* Transport output override. Production omits this (the plugin writes
|
||||
* `process.stdout` — the protocol channel); tests inject an in-memory
|
||||
* `Writable` to capture frames.
|
||||
*/
|
||||
/** Transport output override; production uses `process.stdout`. */
|
||||
output?: Writable
|
||||
/**
|
||||
* Process-exit override for the `shutdown` request path. Production omits
|
||||
* this (`process.exit`); tests inject a recorder so a driven shutdown does
|
||||
* not kill the test process.
|
||||
*/
|
||||
/** Process-exit override; production uses `process.exit`. */
|
||||
exit?: (code: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`)
|
||||
|
||||
Source: [`packages/ui/jsonrpc/src/index.ts:55`](../packages/ui/jsonrpc/src/index.ts)
|
||||
Source: [`packages/ui/jsonrpc/src/index.ts:26`](../packages/ui/jsonrpc/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-deepseek`
|
||||
|
||||
@@ -389,7 +378,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:43`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:30`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-pi-ai`
|
||||
|
||||
@@ -442,7 +431,58 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
|
||||
Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-mcp-client`
|
||||
|
||||
Requires: `tools`
|
||||
|
||||
```ts config-catalog
|
||||
/** Discriminated union of all supported MCP transport configurations. */
|
||||
export type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
/** Config for connecting to an MCP server via a spawned child process over stdio. */
|
||||
export interface StdioConfig {
|
||||
/** Transport type: spawn a child process and communicate over stdio. */
|
||||
transport: 'stdio'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** Executable to spawn. */
|
||||
command: string
|
||||
/** Arguments passed to the command. */
|
||||
args: string[]
|
||||
/** Extra env vars merged on top of scrubbed ambient env. */
|
||||
env: Record<string, string>
|
||||
/** Working directory for the child process. */
|
||||
cwd: string
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
export interface StreamableHttpConfig {
|
||||
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
|
||||
transport: 'streamable-http'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
* (`mcp__<serverName>__<rawName>`). Must match `[A-Za-z0-9_-]{1,32}` and be
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** MCP server URL. */
|
||||
url: string
|
||||
/** Extra headers (e.g. auth tokens). */
|
||||
headers: Record<string, string>
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/mcp/mcp-client/src/index.ts:91`](../packages/mcp/mcp-client/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-mode`
|
||||
|
||||
@@ -483,6 +523,38 @@ export interface ModeDefinition {
|
||||
|
||||
Source: [`packages/mode/mode/src/index.ts:122`](../packages/mode/mode/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-permission`
|
||||
|
||||
Requires: `bash` · `approval`
|
||||
|
||||
```ts config-catalog
|
||||
/** The {@link PermissionService} config: the deployment's preset table. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The preset table: name → knob bundle. Defaults to `workspace-write`
|
||||
* (workspace-write + ask) and `danger-full-access` (danger-full-access +
|
||||
* never). The name `custom` is reserved for the derived not-a-preset state.
|
||||
*/
|
||||
presets?: Record<string, PresetSpec>
|
||||
}
|
||||
|
||||
/** One preset's sandbox/approval bundle and optional client presentation. */
|
||||
export interface PresetSpec {
|
||||
/** The `bash/sandbox-mode` value the preset writes through. */
|
||||
sandbox: SandboxMode
|
||||
/** The `approval/policy` value the preset writes through. */
|
||||
approval: ApprovalPolicy
|
||||
/** The display label a client shows for this preset; the raw table key when omitted. */
|
||||
name?: string
|
||||
/** One user-facing sentence on what the preset means; omitted when not configured. */
|
||||
description?: string
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md)
|
||||
|
||||
Source: [`packages/ui/permission/src/index.ts:80`](../packages/ui/permission/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-repeat-tool-guard`
|
||||
|
||||
```ts config-catalog
|
||||
@@ -513,7 +585,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts)
|
||||
Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-sandbox-local`
|
||||
|
||||
@@ -521,20 +593,10 @@ Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/r
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Override the sandbox runner argv (the bwrap-shaped profile arguments are
|
||||
* appended). A NON-EMPTY argv is the operator's assertion that this runner
|
||||
* exists and FULLY enforces the profile (confinement reports
|
||||
* `enforcement: 'full'`, and — the runner's kernel mechanism being unknown
|
||||
* — carries both Linux file-denial dialects as its denial signatures) —
|
||||
* the runner chain and its probes are skipped,
|
||||
* and a broken runner fails loudly at execution time. The operator also
|
||||
* supplies {@link runnerFailureSignatures}, which distinguish the runner
|
||||
* refusing its profile from the wrapped command failing normally.
|
||||
* Absent (or empty — the schema normalizes an omitted array to `[]`): the
|
||||
* built-in platform chains — Linux `bwrap` then the Landlock launcher
|
||||
* (probed in that order), darwin `sandbox-exec` (the sole candidate,
|
||||
* selected without a probe). Used for custom/alternative runners and
|
||||
* for deterministic fake runners in keyless test tiers.
|
||||
* Override the runner argv; bwrap-shaped profile arguments are appended. A
|
||||
* non-empty override asserts full enforcement and skips built-in selection and
|
||||
* probing; a broken runner then fails at execution and must be identifiable by
|
||||
* {@link runnerFailureSignatures}.
|
||||
*/
|
||||
runnerCommand?: string[]
|
||||
/**
|
||||
@@ -546,21 +608,12 @@ export interface Config {
|
||||
* own failure dialect.
|
||||
*/
|
||||
runnerFailureSignatures?: string[]
|
||||
/**
|
||||
* Per-probe timeout in milliseconds for the chain's functional probes
|
||||
* (default: 5000; must be a positive finite number — Node treats a 0
|
||||
* `spawnSync` timeout as UNBOUNDED, so 0 is rejected at construction). A
|
||||
* probe that exceeds it reads as an unusable rung, so a
|
||||
* host slow enough to trip the default — cold NFS mounts, heavily loaded
|
||||
* CI — would otherwise be misclassified `SANDBOX_UNAVAILABLE` with no
|
||||
* config escape. Bounds ONE probe, and the chain walk runs each at most once
|
||||
* per provider lifetime.
|
||||
*/
|
||||
/** Positive timeout for each functional probe; zero would mean unbounded to Node. */
|
||||
probeTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/sandbox/sandbox-local/src/index.ts:36`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
Source: [`packages/sandbox/sandbox-local/src/index.ts:20`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-jsonl`
|
||||
|
||||
@@ -578,7 +631,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:35`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-sqlite`
|
||||
|
||||
@@ -613,7 +666,21 @@ export interface Config {
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-query`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for exact session-query reads. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-skill`
|
||||
|
||||
@@ -645,13 +712,29 @@ export interface Config {
|
||||
|
||||
Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-stdio-agent`
|
||||
## `@deepseek-ai/dsh-stdio`
|
||||
|
||||
Requires: `agents` · `userInteraction`
|
||||
|
||||
```ts config-catalog
|
||||
/** Serializable plugin configuration (cordis-native, schemastery). */
|
||||
export interface Config {
|
||||
/** Banner printed once on start, before the first `> ` prompt. */
|
||||
welcome?: string
|
||||
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
|
||||
agent?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/stdio/src/index.ts:33`](../packages/ui/stdio/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-stdio-demo`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* App config: the swappable per-demo values, each routed to where the app wires
|
||||
* it. `model`/`resumeSessionId` configure the pre-created `main` agent (through
|
||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||
* {@link @deepseek-ai/dsh-agent-spine-demo}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
@@ -665,14 +748,18 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/** Model-facing bash tool config forwarded through agent-core. */
|
||||
toolBash?: NonNullable<agentCore.Config['toolBash']>
|
||||
/** Generic background-task control-tool config forwarded through agent-core. */
|
||||
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -682,9 +769,9 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
|
||||
Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts)
|
||||
Source: [`packages/examples/stdio-demo/src/index.ts:36`](../packages/examples/stdio-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-acp`
|
||||
|
||||
@@ -727,20 +814,11 @@ export interface Config {
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How the client answers a child's `session/request_permission`. The first cut
|
||||
* does not surface permission prompts to a human, so every request is
|
||||
* auto-answered by this fixed policy:
|
||||
*
|
||||
* - `reject` — decline every prompt (answer `cancelled`). Safe default: a child
|
||||
* that asks before a side effect does not get to take it.
|
||||
* - `allow` — approve every prompt by selecting its first `allow_*` option (or,
|
||||
* if none is offered, `cancelled`). Use when the child is trusted to act.
|
||||
*/
|
||||
/** Fixed response to child permission requests: reject by default, or select the first allow option. */
|
||||
export type PermissionPolicy = 'allow' | 'reject'
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/subagent-acp/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-acp/src/index.ts:18`](../packages/subagent/subagent-acp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-fork`
|
||||
|
||||
@@ -754,7 +832,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-fork/src/index.ts:38`](../packages/subagent/subagent-fork/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-mock`
|
||||
|
||||
@@ -789,7 +867,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:97`](../packages/support/subagent-mock/src/index.ts)
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-spawn`
|
||||
|
||||
@@ -803,7 +881,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-spawn/src/index.ts:35`](../packages/subagent/subagent-spawn/src/index.ts)
|
||||
Source: [`packages/subagent/subagent-spawn/src/index.ts:20`](../packages/subagent/subagent-spawn/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
@@ -811,48 +889,50 @@ Source: [`packages/subagent/subagent-spawn/src/index.ts:35`](../packages/subagen
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
* system prompt, rendered as the order-0 `deployment:persona` section
|
||||
* (after the harness identity, before all tool guidance). Every agent in
|
||||
* the context shares it by default; a per-agent persona is a SCOPED section
|
||||
* of the same name registered through that agent's `agent.ctx` (it shadows
|
||||
* this one for that agent — the subagent seam's `persona` request field does
|
||||
* exactly that). Template, not free-form text:
|
||||
* every complete `{{…}}` group is interpreted strictly against the
|
||||
* registered prompt variables (the shipped agent loop registers `{{model}}`
|
||||
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
|
||||
* yet (a deliberate deferral; see the prompt-variables RFC). Defaults to
|
||||
* `''` — the empty section is dropped at render, so a persona-less
|
||||
* deployment opens with the harness identity alone.
|
||||
* Deployment-wide order-0 persona template. A scoped section named
|
||||
* `deployment:persona` shadows it; `{{variable}}` references are strict.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Explicit model-facing tool order, as a list of `ToolSchema.name`s: listed
|
||||
* tools take their listed position, and tools absent from the list are
|
||||
* inserted at the {@link TOOL_ORDER_REST} (`'<unlisted-tools>'`) entry in
|
||||
* lexicographic name order. A configured list must contain the rest entry
|
||||
* exactly once, no duplicate names, and no name without a registered tool —
|
||||
* a misconfigured order blocks work instead of silently reaching a model
|
||||
* request: shape violations throw at load, and an unregistered name rejects
|
||||
* every assembly. `TOOL_ORDER_REST` is reserved for the list marker and may
|
||||
* not be a collected tool name; such a provider output also rejects the
|
||||
* assembly. The single assembly-time validation rejects either failure
|
||||
* before any model request — the earliest moment the registered tool set
|
||||
* exists to check against, since tool plugins register after this service
|
||||
* constructs. When omitted, tools are ordered lexicographically by name.
|
||||
* Applied to the tools
|
||||
* {@link SystemPrompt.assemble} collects, BEFORE the
|
||||
* `system-prompt/assemble` waterfall — like the sections' `order` sort, it
|
||||
* canonicalizes what the registry contributed (registration order is a
|
||||
* plugin-load artifact); a waterfall listener that mutates the tool list
|
||||
* owns the determinism of what it emits. Rationale (and why not per-plugin
|
||||
* weights): docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md.
|
||||
* Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once.
|
||||
* Shape errors fail at load and unknown names fail at assembly; known names
|
||||
* hidden in one scope may be absent there. Omitted means lexicographic order.
|
||||
*/
|
||||
toolOrder?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:227`](../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-time-context`
|
||||
|
||||
Requires: `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */
|
||||
export interface Config {
|
||||
/** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */
|
||||
timeZone?: string
|
||||
/** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */
|
||||
refreshIntervalMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash`
|
||||
|
||||
Requires: `tools` · `bash` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configures whether the model may background commands. */
|
||||
export interface Config {
|
||||
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
|
||||
enableRunInBackground?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts:37`](../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-cordis`
|
||||
|
||||
@@ -870,7 +950,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts:53`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts:25`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
@@ -890,7 +970,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts:48`](../packages/fs/tool-fs/src/index.ts)
|
||||
Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-skill`
|
||||
|
||||
@@ -916,34 +996,29 @@ export interface Config {
|
||||
/** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */
|
||||
provider: string
|
||||
/**
|
||||
* The model-facing tool name to register (default `subagent`). To expose more
|
||||
* than one transport, load this plugin once per provider — each load MUST set
|
||||
* a distinct `toolName` (the tool registry rejects a duplicate name), e.g.
|
||||
* `{ provider: 'spawn', toolName: 'subagent' }` and
|
||||
* `{ provider: 'acp', toolName: 'subagent_acp' }`.
|
||||
* Model-facing tool name (default `subagent`). Each loaded instance must use
|
||||
* a distinct name.
|
||||
*/
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults.
|
||||
* Expose `run_in_background` (default true). Disabled instances omit the
|
||||
* parameter and reject forced background calls.
|
||||
*/
|
||||
enableRunInBackground?: boolean
|
||||
/**
|
||||
* Agent options applied to every child; omitted fields use child-loop defaults.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
/**
|
||||
* Per-child persona applied to every child this tool spawns: a scoped
|
||||
* `deployment:persona` section shadowing the deployment's persona for the
|
||||
* child alone. Requires the bound provider's `persona` capability
|
||||
* (in-process backends support it; a request against one that doesn't is
|
||||
* rejected at start). Omitted ⇒ the child renders the deployment persona.
|
||||
* Per-child persona that shadows `deployment:persona`. Requires the
|
||||
* provider's `persona` capability; omission preserves the deployment persona.
|
||||
*/
|
||||
persona?: string
|
||||
/**
|
||||
* Tool scoping applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.toolFilter`): the named global tools vanish from
|
||||
* the child's prompt AND refuse to execute. Requires the provider's
|
||||
* `toolFilter` capability. Unknown names fail the spawn loudly. Note the
|
||||
* child otherwise sees every global tool — including this delegation tool
|
||||
* itself; `deny`-listing it (or setting `maxDepth`) is how a deployment
|
||||
* bounds recursion.
|
||||
* Tool filter applied to every child. Filtered tools disappear from its
|
||||
* prompt and reject execution. Requires the provider's `toolFilter`
|
||||
* capability; unknown names fail startup. Children otherwise see this tool,
|
||||
* so deny it or set `maxDepth` to bound recursion.
|
||||
*/
|
||||
toolFilter?: {
|
||||
/** Global tool names the child keeps; everything else is removed. */
|
||||
@@ -952,12 +1027,8 @@ export interface Config {
|
||||
deny?: string[]
|
||||
}
|
||||
/**
|
||||
* Recursion cap applied to every child this tool spawns (see
|
||||
* `SubagentStartRequest.maxDepth`): a spawn whose child would sit deeper
|
||||
* than this in the delegation tree is rejected. Requires the provider's
|
||||
* `depthLimit` capability. Must be a non-negative safe integer and is
|
||||
* validated when the plugin loads. Omitted ⇒ unbounded (bound it in
|
||||
* deployments that expose this tool to children).
|
||||
* Maximum child depth. Requires the provider's `depthLimit` capability and a
|
||||
* non-negative safe integer. Omission is unbounded.
|
||||
*/
|
||||
maxDepth?: number
|
||||
}
|
||||
@@ -965,7 +1036,23 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:23`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-tasks`
|
||||
|
||||
Requires: `tools` · `tasks` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configures bounded `task_output` waits. */
|
||||
export interface Config {
|
||||
/** Wait duration applied when `task_output` sets `wait` without `timeout_ms` (default 30s). */
|
||||
waitTimeoutMs?: number
|
||||
/** Hard cap on any single wait; a larger model-supplied `timeout_ms` is clamped down to it (default 10min). */
|
||||
maxWaitTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/tasks/tool-tasks/src/index.ts:21`](../packages/tasks/tool-tasks/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
@@ -987,7 +1074,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/tool-web/src/index.ts:40`](../packages/web/tool-web/src/index.ts)
|
||||
Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-workflow`
|
||||
|
||||
@@ -1003,7 +1090,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/tool-workflow/src/index.ts:39`](../packages/workflow/tool-workflow/src/index.ts)
|
||||
Source: [`packages/workflow/tool-workflow/src/index.ts:26`](../packages/workflow/tool-workflow/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tools`
|
||||
|
||||
@@ -1013,18 +1100,10 @@ Requires: `systemPrompt`
|
||||
/** Plugin config: how the registered tools are presented to the model. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
* or mismatched runtime rejects every prompt assembly with an actionable
|
||||
* error (misconfiguration fails loud, before any model request). A
|
||||
* configured `systemPrompt.toolOrder` naming native tools likewise rejects
|
||||
* every assembly under `'code'` (those names are no longer contributed) —
|
||||
* a deployment switching modes updates its order config or drops it.
|
||||
* Model presentation. `native` (default) sends every visible schema; `code`
|
||||
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
|
||||
* Code modes require a TypeScript runtime and fail prompt assembly when it is
|
||||
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
}
|
||||
@@ -1033,7 +1112,7 @@ export interface Config {
|
||||
export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:401`](../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
@@ -1064,7 +1143,7 @@ export interface Config {
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
```
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:270`](../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:214`](../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web`
|
||||
|
||||
@@ -1083,7 +1162,7 @@ export interface WebServiceConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:68`](../packages/web/web/src/index.ts)
|
||||
Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-fetch-local`
|
||||
|
||||
@@ -1098,10 +1177,8 @@ export interface Config {
|
||||
maxResponseBytes?: number
|
||||
/** Maximum decoded body length in characters. */
|
||||
maxBodyChars?: number
|
||||
/** Default fetch timeout in milliseconds. */
|
||||
/** Default fetch timeout in milliseconds, within Node's timer range. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for a per-request timeout override. */
|
||||
maxTimeoutMs?: number
|
||||
/** Maximum number of same-origin redirect hops to follow. */
|
||||
maxRedirects?: number
|
||||
/** `User-Agent` header sent on every request. */
|
||||
@@ -1109,7 +1186,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts)
|
||||
Source: [`packages/web/web-fetch-local/src/index.ts:36`](../packages/web/web-fetch-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-deepseek`
|
||||
|
||||
@@ -1133,7 +1210,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:48`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:40`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
@@ -1205,7 +1282,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow-workerthread/src/index.ts:69`](../packages/workflow/workflow-workerthread/src/index.ts)
|
||||
Source: [`packages/workflow/workflow-workerthread/src/index.ts:32`](../packages/workflow/workflow-workerthread/src/index.ts)
|
||||
|
||||
## Loadable plugins with no config
|
||||
|
||||
@@ -1217,9 +1294,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
|
||||
@@ -1243,7 +1320,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
|
||||
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
|
||||
- `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
|
||||
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))
|
||||
|
||||
6
docs/cookbook/adding-a-package.i18n.yaml
Normal file
6
docs/cookbook/adding-a-package.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-package.md: 2930cee9ab64b382f6211335ae639bce45629d1d
|
||||
adding-a-package.zh.md: 10e906c320203c3658c103fc8936540f72697d65
|
||||
@@ -1,6 +1,8 @@
|
||||
# Cookbook: adding a workspace package
|
||||
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. (Verified by the bash and adapter packages; if it drifts, fix it here.)
|
||||
English | [中文](adding-a-package.zh.md)
|
||||
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. This checklist is validated against the bash and adapter packages as templates; if it drifts from them, fix it here.
|
||||
|
||||
## 1. Create the package
|
||||
|
||||
@@ -13,7 +15,10 @@ packages/<group>/<pkg>/
|
||||
# you use Config, + ../../<group>/<dep> for each dsh dep)
|
||||
src/index.ts # service default export or plugin (name/inject/apply/Config)
|
||||
tests/<x>.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 +42,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
|
||||
|
||||
83
docs/cookbook/adding-a-package.zh.md
Normal file
83
docs/cookbook/adding-a-package.zh.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 实操手册:添加 workspace 包(package)
|
||||
|
||||
[English](adding-a-package.md) | 中文
|
||||
|
||||
为新建 `@deepseek-ai/dsh-<name>` 包提供的逐文件清单。本清单以 bash 和 adapter 这两个包为模板进行验证;如果清单与模板有出入,请在此修正。
|
||||
|
||||
## 1. 创建包
|
||||
|
||||
```
|
||||
packages/<group>/<pkg>/
|
||||
package.json # copy from packages/core/tools, adjust name/description/deps
|
||||
tsconfig.json # extends ../../../tsconfig.base.json, rootDir src,
|
||||
# outDir lib/types, references: ../../../vendor/cosmokit,
|
||||
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
|
||||
# you use Config, + ../../<group>/<dep> for each dsh dep)
|
||||
src/index.ts # service default export or plugin (name/inject/apply/Config)
|
||||
tests/<x>.spec.ts
|
||||
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)
|
||||
```
|
||||
|
||||
当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。
|
||||
|
||||
package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表要精确:`lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map` 和 `src`;不要发布 `lib/types` 下的 JS 或 JS-map 中间产物,也不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。
|
||||
|
||||
包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。
|
||||
|
||||
## 2. 在根配置中注册
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) |
|
||||
|
||||
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。
|
||||
|
||||
## 3. 确定包拓扑
|
||||
|
||||
对于可替换的能力,将接口、实现、消费方拆分为独立的包(见 docs/architecture.md § "Capability seams"——bash 三组件是模板)。单一用途的插件保持为一个包。
|
||||
|
||||
## 4. 编写包 README
|
||||
|
||||
将包特有的服务 API、配置、事件、扩展点和设计说明放在前面。limitations 部分记录持久的消费方缺口和本包拥有的非显而易见的维护者约束;日常清理事项留在源码 TODO 或 RFC 中。间接的 Model Experience 语句可以点名暴露本包贡献的消费方,但不重述该消费方的实现。包 README 以如下规范序列结尾:
|
||||
|
||||
````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.
|
||||
````
|
||||
|
||||
根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述两个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。
|
||||
|
||||
没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
测试要求:每个注册表/注册操作都需要一个 HMR(热模块替换)安全测试(从子 fiber 注册,dispose(资源释放)它,断言清理完成)。鼓励编写充分的测试——见 [docs/testing.md](../testing.md)。
|
||||
6
docs/cookbook/adding-a-tool.i18n.yaml
Normal file
6
docs/cookbook/adding-a-tool.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-tool.md: da214702939e01fedf3d0d69be7560bbafe0696a
|
||||
adding-a-tool.zh.md: b216d18b1593cd7e6074685bd39684f1b9694eac
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding a tool
|
||||
|
||||
English | [中文](adding-a-tool.zh.md)
|
||||
|
||||
How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam).
|
||||
|
||||
## The minimal shape
|
||||
@@ -36,24 +38,24 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool (w
|
||||
- **Args are validated for you.** `defineTool` validates the model-generated `arguments` against the `SchemaSpec` before `execute` runs (type, required keys, enum membership, nested objects/arrays — [runtime arg validation](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)), so inside `execute` the args already match `InferArgs`. You still hand-check value constraints the DSL can't express (non-empty strings, positive numbers, cross-field rules); throw a descriptive Error for those. Raw JSON-Schema tools registered directly (MCP) are NOT validated by the harness — they validate their own input.
|
||||
- **Registration borrows your readonly definition.** A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration. `schemas()` materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state.
|
||||
- **Execution identity is protected.** The registry materializes `arguments` as detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaque `exec.token`; `callId`, `name`, `arguments`, `agent`, `token`, and an optional enclosing-transport `parent` token stay immutable through dispatch. `parent` is identity-only and exposes no live outer execution. Treat `args` as readonly input. An around-dispatch wrapper may add, replace, or remove only `exec.signal` to impose cancellation or a deadline.
|
||||
- **Throwing or returning non-JSON data means isError.** The registry catches anything `execute()` throws and materializes the complete post-policy result as lossless JSON before final observers run. A throw, malformed result, or non-JSON content/context/meta becomes `{isError: true}` so the live outcome cannot succeed and then fail at the durable log. Use errors for infrastructure failures (bad input, spawn errors, aborts), but report domain failures in the result text instead (for example, tool-bash returns `[exit code: 9]` with `isError: false` because the model decides what a failing command means).
|
||||
- **Throwing or returning non-JSON data means `isError`.** The registry catches throws and materializes the final result before observers run. A malformed or non-JSON result becomes `{ isError: true }`, preventing a live success that cannot be logged. Throw for infrastructure failures; report domain failures in result text when the model must interpret them.
|
||||
- **Honor `exec.signal`.** Cancel in-flight work when it fires.
|
||||
- **Attach durable card data with `meta` (optional).** `execute` may return `{ content, meta }` instead of a bare `ContentBlock[]` — `meta` is a JSON-serializable payload the core treats as opaque, persisted on the `tool/result` event and handed back to your `presentResult` (so a card that needs more than `args`, like `write`/`edit`'s applied-hunk diff, survives a session replay). Keep UI-only data here, never in the model-facing `content`.
|
||||
- **Use `exec.agent` for async notifications.** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
|
||||
|
||||
## Long-running work
|
||||
|
||||
Follow tool-bash's background pattern: a `run_in_background` flag returns a task id immediately; companion tools poll incrementally and kill; completion notices arrive via `agent.inject()`. Bound buffers and spill full output to disk so nothing is silently lost.
|
||||
Gate `run_in_background` with producer config, reject a pre-aborted call, then register through `ctx.tasks.start({ kind, label, owner: exec.agent, run })`. The runtime validates ownership and control-surface availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup.
|
||||
|
||||
> TODO: each tool reimplements this background pattern by hand today. At some point we need a generic long-running-tool layer that handles task ids, incremental polling, kill, and completion notices uniformly.
|
||||
The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. Once the id is returned, use a task-owned cancellation signal rather than `exec.signal`. See the [background task runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
|
||||
|
||||
## Execution policy and observation
|
||||
|
||||
Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points).
|
||||
Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points).
|
||||
|
||||
## Code Mode reaches your tool for free
|
||||
|
||||
Under the registry's non-native `mode` ([Code Mode](../../packages/core/tools/README.md)), each visible registered capability is callable from a `run_code` program as `await tools.<name>(args)` — nothing to add. The registry keeps `run_code` itself as reserved, unfilterable presentation infrastructure while restrictions still control which end capabilities appear in the scoped SDK and bindings. The generated SDK declares parameters from the same JSON Schema `defineTool` emits (constructs outside that subset degrade to `unknown`); each program call receives its own immutable execution whose `parent` is the enclosing `run_code` token, then re-enters the complete pre/guard/around/post/result pipeline. A failed call rejects the program-side promise with your error text. Design `description` and parameter `description`s as JSDoc a model reads while writing code, and remember that non-text result blocks reach programs as placeholders (text is the bridge's lingua franca).
|
||||
In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.<name>(args)` without extra integration. The SDK derives parameters from the same JSON Schema, and calls re-enter the normal execution pipeline. Write descriptions as model-facing API docs; non-text result blocks become placeholders in programs.
|
||||
|
||||
## How your tool renders in an editor (ACP presentation)
|
||||
|
||||
@@ -65,7 +67,10 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it.
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.)
|
||||
- `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.)
|
||||
- `presentResult(args, { content, isError, meta? })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability), or `{ card: 'diff', title?, diffs }` (a completed file mutation — the applied hunks computed from the before/after content when there is a before-image, else a whole-file diff for a create; `write`/`edit` attach the hunks via the `meta` channel and read them back here). A mutation tool returns the `diff` result even when it duplicates the call-time card, because an ACP `tool_call_update.content` REPLACES the call's content — a non-diff result would clobber the pending diff. `result.meta` is your tool's own optional presentation payload, attached from `execute` (see below) and persisted so a replay reproduces the card.
|
||||
- `presentResult(args, { content, isError, meta? })` returns the completed card:
|
||||
- `generic` supplies an optional title and content.
|
||||
- `terminal` supplies raw output and optional exit metadata; the bridge renders the capability-specific or fenced fallback view.
|
||||
- `diff` supplies applied hunks, often carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because an ACP update replaces the pending card's content.
|
||||
|
||||
Hard rules (they bite if broken):
|
||||
|
||||
@@ -77,4 +82,4 @@ The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a too
|
||||
|
||||
## Tests every tool needs
|
||||
|
||||
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path.
|
||||
Cover argument rejection, every result shape, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and `tool/result` session events. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path.
|
||||
|
||||
85
docs/cookbook/adding-a-tool.zh.md
Normal file
85
docs/cookbook/adding-a-tool.zh.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 实操手册:添加工具
|
||||
|
||||
[English](adding-a-tool.md) | 中文
|
||||
|
||||
如何为模型赋予一项新能力。参考实现:`examples/echo-agent/src/echo-tool.ts`(最小化)和 `packages/bash/tool-bash`(生产级,由三个包(package)构成的 seam)。
|
||||
|
||||
## 最小形态
|
||||
|
||||
```ts
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'my-tool'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read_file',
|
||||
description: 'Read a file from disk.', // what the model sees
|
||||
parameters: {
|
||||
path: { type: 'string', required: true, description: 'Absolute path' },
|
||||
limit: { type: 'number' }, // optional by default
|
||||
},
|
||||
async execute(args, exec) {
|
||||
// args is TYPED from the schema: { path: string; limit?: number }
|
||||
// exec carries immutable identity + token; signal is the operational field
|
||||
return [{ type: 'text', text: await readFile(args.path, 'utf8') }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具(请编写 HMR(热模块替换)测试)。schema 会自动流入系统提示词的组装过程。
|
||||
|
||||
## execute() 契约的规则
|
||||
|
||||
- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。
|
||||
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
|
||||
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。
|
||||
- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。
|
||||
- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。
|
||||
- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。
|
||||
- **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。
|
||||
|
||||
## 长时间运行的工作
|
||||
|
||||
通过 producer 配置控制 `run_in_background`,拒绝已预先中止的调用,然后使用 `ctx.tasks.start({ kind, label, owner: exec.agent, run })` 注册任务。运行时会在 `run()` 启动工作前校验 owner 和控制面是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。
|
||||
|
||||
producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。返回 id 后,应使用 task 自有的取消信号,而不是 `exec.signal`。流式 producer 的示例和完整契约见[后台 task 运行时 RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与 `dsh-tool-bash`。
|
||||
|
||||
## 执行策略与观测
|
||||
|
||||
尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](./extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝(后续监听器无法撤销);使用 `tools/execute` 为核心分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 转换或附加模型可见的上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。
|
||||
|
||||
## Code Mode 自动触达你的工具
|
||||
|
||||
在 [Code Mode](../../packages/core/tools/README.md) 中,每个可见的已注册工具都可通过 `await tools.<name>(args)` 调用,无需额外集成。SDK 从同一份 JSON Schema 派生参数,调用重新进入正常的执行流水线。请将描述写成面向模型的 API 文档;非文本结果块在程序中变为占位符。
|
||||
|
||||
## 工具在编辑器中的渲染方式(ACP 展示)
|
||||
|
||||
工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。
|
||||
|
||||
两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型:
|
||||
|
||||
- `presentCall(args)` → 一个 `ToolCallView`(PENDING 卡片):
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`——默认。设置 `kind` 获取图标(`read`/`search`/…);设置 `locations: [{ path, line? }]` 标注工具涉及的文件,使有能力的编辑器跟随/跳转。
|
||||
- `{ card: 'terminal', title, description?, cwd? }`——你的调用本身就是 shell 命令。`title` 是命令,`description` 渲染在终端卡片上方。(tool-bash。)
|
||||
- `{ card: 'diff', title, diffs, locations? }`——你的调用创建或修改文件。`diffs: [{ path, oldText, newText }]`(新文件时 `oldText: null`)渲染为内联 diff 卡片。(tool-fs `write`/`edit`。)
|
||||
- `presentResult(args, { content, isError, meta? })` 返回完成后的卡片:
|
||||
- `generic` 提供可选的标题和内容。
|
||||
- `terminal` 提供原始输出和可选的退出元数据;桥接层渲染能力特定或围栏回退视图。
|
||||
- `diff` 提供已应用的 hunk,通常由持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。
|
||||
|
||||
硬性规则(违反会出问题):
|
||||
|
||||
- **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);**桥接层**(而非工具)填充会话 cwd 并相对化展示路径标题。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下——那属于桥接层或未来的 result-event 形态,不属于展示器。
|
||||
- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径——这些都不得出现在 `execute` 返回给模型的内容中;它们只存在于展示层。(`terminal` 结果视图携带原始 `output`;桥接层添加围栏。)
|
||||
- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。
|
||||
|
||||
中性词汇定义在 `dsh-tools` 中(绝不在工具中导入 ACP 类型);ACP 桥接层将每个 `card` 映射到协议格式(wire format)。设计与原因见[渲染意图联合体 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
|
||||
|
||||
## 每个工具必须的测试
|
||||
|
||||
覆盖参数拒绝、每种结果形态和 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和 `tool/result` 会话事件。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。
|
||||
6
docs/cookbook/adding-a-vendored-package.i18n.yaml
Normal file
6
docs/cookbook/adding-a-vendored-package.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-vendored-package.md: d7b5b93b59fb39d8369be6eb42fb0a8b977c68b4
|
||||
adding-a-vendored-package.zh.md: 86b1e6c959180ba15b6fcb56b6dfe5a3be791b47
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding a vendored package
|
||||
|
||||
English | [中文](adding-a-vendored-package.zh.md)
|
||||
|
||||
When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-http`), it is **vendored** as pinned source under `vendor/`, not added as an npm dependency — see [the vendoring decision](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md) for why. [vendor/README.md](../../vendor/README.md) covers *updating* an already-vendored package; this guide is the file-by-file checklist for adding a **new** one. (Verified against the existing vendored set; if it drifts, fix it here.)
|
||||
|
||||
## 1. Copy the source in
|
||||
|
||||
60
docs/cookbook/adding-a-vendored-package.zh.md
Normal file
60
docs/cookbook/adding-a-vendored-package.zh.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 实操手册:添加一个 vendored 包(package)
|
||||
|
||||
[English](adding-a-vendored-package.md) | 中文
|
||||
|
||||
当 harness 需要引入另一个上游 Cordis 包(如 `@cordisjs/plugin-http`)时,应将其作为固定版本的源码 **vendor** 到 `vendor/` 下,而非作为 npm 依赖添加——原因见[vendoring 决策](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md)。[vendor/README.md](../../vendor/README.md) 介绍如何*更新*已有的 vendored 包;本指南是添加**新** vendored 包的逐文件清单。(已对照现有 vendored 集合验证;如有偏差,请在此修正。)
|
||||
|
||||
## 1. 复制源码
|
||||
|
||||
```
|
||||
vendor/<dir>/
|
||||
package.json # from upstream; set "private": true, keep name/exports/type
|
||||
tsconfig.json # extends ../../tsconfig.base.json (see shape below)
|
||||
src/ # the upstream src/ verbatim
|
||||
README.md LICENSE # if upstream ships them
|
||||
```
|
||||
|
||||
`tsconfig.json` 与其他 vendored 包保持一致:`rootDir: src`、`outDir: lib/types`、上游代码所需的严格性放宽项,以及对所导入的每个其他 vendored 包的 `references` 条目:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src", "outDir": "lib/types",
|
||||
"noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false,
|
||||
"noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../cordis" }, { "path": "../cosmokit" }]
|
||||
}
|
||||
```
|
||||
|
||||
`package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。
|
||||
|
||||
vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。
|
||||
|
||||
## 2. 在根配置中注册
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 在 `paths` 中添加 `"<npm-name>": ["./vendor/<dir>/src"]` |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前) |
|
||||
| `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 |
|
||||
| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) |
|
||||
|
||||
以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。
|
||||
|
||||
## 3. 注意 manifest 守卫
|
||||
|
||||
`scripts/check-vendor-manifest.sh`(pre-commit 钩子)会在 `vendor/*/src` 下有暂存改动但 `vendor/README.md` 未一起暂存时失败。请将 manifest 更新与源码一起暂存,以通过提交检查。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
```sh
|
||||
pnpm install # registers the workspace
|
||||
pnpm run typecheck
|
||||
pnpm run build && pnpm run test && pnpm run constraints
|
||||
```
|
||||
|
||||
源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入根目录的严格程序中。
|
||||
6
docs/cookbook/adding-an-llm-adapter.i18n.yaml
Normal file
6
docs/cookbook/adding-an-llm-adapter.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-an-llm-adapter.md: 70306ccf119f523dd812a859eef1e8628383bb48
|
||||
adding-an-llm-adapter.zh.md: e6d151adfc793a829a876031d5d7280ba078c8e9
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding an LLM adapter
|
||||
|
||||
English | [中文](adding-an-llm-adapter.zh.md)
|
||||
|
||||
How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
|
||||
|
||||
## The shape
|
||||
|
||||
45
docs/cookbook/adding-an-llm-adapter.zh.md
Normal file
45
docs/cookbook/adding-an-llm-adapter.zh.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 实操手册:添加 LLM 适配器
|
||||
|
||||
[English](adding-an-llm-adapter.md) | 中文
|
||||
|
||||
如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
|
||||
|
||||
## 基本形态
|
||||
|
||||
```ts ignore-check
|
||||
class MyAdapter extends LlmAdapter {
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { … }
|
||||
}
|
||||
|
||||
export const name = 'llm-myprovider'
|
||||
export const inject = ['llm']
|
||||
export const Config: z<Config> = z.object({ apiKey: z.string(), … })
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.llm.registerAdapter(['model-a', 'model-b'], new MyAdapter(…))
|
||||
}
|
||||
```
|
||||
|
||||
注册基于副作用(HMR 安全);每个模型名称对应一个适配器,重复注册会抛出异常。密钥采用 Cordis 原生方式管理:schemastery Config 带环境变量回退,通过 cordis.yml 的 `!!js process.env.MY_KEY` 注入。代码中禁止临时读取密钥文件。
|
||||
|
||||
## 协议义务(两个实现共同验证的契约)
|
||||
|
||||
- 在 `finish` **之前**发出 `usage`;`finish` 之后**不再发出任何内容**。稳健做法:缓冲 finish/usage 直到提供方的流结束标记,再统一 flush(可处理提供方在末尾发送仅含 usage 的分片的情况)。
|
||||
- 工具调用的 `arguments` 全程为原始 JSON 字符串;流式片段以 `argumentsDelta` 发送。如果你的提供方返回已解析的对象,请在 `block-end` 时重新 stringify。
|
||||
- 按首次出现的流顺序分配块 `index`;同一个块的每次 delta 复用该 index。
|
||||
- 错误有且仅有两条合法路径:从 `stream()` **抛出**(传输与协议故障——使用带稳定 code 的 `LlmError`),或以 `finish {kind: 'error' | 'aborted'}` 结束流(提供方带内故障)。消费方两者都处理;按故障类别选择路径并加以文档化。
|
||||
- 遵守 `options.signal`(将其传递给 fetch 或你的 SDK)。
|
||||
- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。
|
||||
|
||||
提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。
|
||||
|
||||
## 经验证有效的结构
|
||||
|
||||
将适配器拆分为可测试的阶段(llm-deepseek 的布局):协议格式(wire format)类型(`types.ts`,豁免覆盖率)→ 请求序列化器 → SSE/传输解析器 → 分片转换状态机 → 一个将它们串联的薄适配器类。每个阶段配备独立的单元测试套件。
|
||||
|
||||
## 测试
|
||||
|
||||
- **单元测试:mock 提供方,而非 harness。** 用脚本化的 `node:http` 服务器模拟提供方的协议格式,覆盖正常路径、所有错误状态码、畸形载荷、连接提前关闭和中止——无需网络,且能满足 100% 逐文件覆盖率门禁。对基于 SDK 的适配器同样适用(将 SDK 的 baseURL 指向 mock 服务器)。
|
||||
- **恶意分帧测试。** 在任意字节位置(包括 UTF-8 字符中间)切割流载荷——真实网络环境正是如此。
|
||||
- **E2E:`tests/*.e2e.ts`**,通过 `pnpm run test:e2e` 运行,以 `describe.skipIf(!process.env.MY_KEY)` 守卫,确保无密钥的 CI 保持绿色。覆盖你映射的每个模型 × 每种提供方模式(thinking 开/关、effort 级别)、一次包含后续轮次(历史中带工具结果)的工具调用往返,以及仅做宽松断言(子串/结构匹配、有界的 maxTokens——真实模型是非确定性的)。
|
||||
- 在 `knip.json` 中注册 e2e 文件模式(per-workspace `entry` 覆盖),否则 knip 会将其标记为未使用。
|
||||
6
docs/cookbook/extension-cookbook.i18n.yaml
Normal file
6
docs/cookbook/extension-cookbook.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
extension-cookbook.md: 3a8506017c2ff1afdea92aae40b04513b82abc73
|
||||
extension-cookbook.zh.md: 2190626219e651bcda1d0a88a06aab53d0603693
|
||||
@@ -1,14 +1,18 @@
|
||||
# Cookbook: extension plugin shapes
|
||||
|
||||
English | [中文](extension-cookbook.zh.md)
|
||||
|
||||
> FIXME: This important guide has not received sufficient human design review; complete that review before the first release.
|
||||
|
||||
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see [adding a package](./adding-a-package.md), [adding a tool](./adding-a-tool.md), and [adding an LLM adapter](./adding-an-llm-adapter.md); for the seams these hook into see [docs/architecture.md](../architecture.md).
|
||||
|
||||
## A tool plugin
|
||||
|
||||
A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](./adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools.
|
||||
|
||||
## A hook plugin (permission gate)
|
||||
## A hook plugin (permission-gate example)
|
||||
|
||||
A hook returns a typed decision from the `tools/pre-execute` gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.)
|
||||
This permission gate is one example of a hook plugin. It returns a typed decision from the `tools/pre-execute` gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this seam. Hook plugins can intercept other seams and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception seam; it needs no external protocol.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
@@ -56,7 +60,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
## A client-driver plugin (external protocol bridge)
|
||||
|
||||
A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: correlate and settle each request exactly once from the durable `turn/end` session event even if rendering fails, and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it.
|
||||
A *client driver* is a UI plugin for a wire-protocol peer. It owns stdio, so stdout logging must be disabled, creates or resumes agents through the factory, maps harness events to protocol messages, and maps requests to `send()` or `cancel()`. Settle each request exactly once from durable `turn/end`, even if rendering fails, and tear agents down with `AgentHandle.dispose()` so disposal reaches quiescence.
|
||||
|
||||
`packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
|
||||
|
||||
@@ -83,7 +87,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
## Runnable wirings
|
||||
|
||||
Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent), the ACP demo loads [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle.
|
||||
Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle.
|
||||
|
||||
## The feature → mechanism map
|
||||
|
||||
|
||||
125
docs/cookbook/extension-cookbook.zh.md
Normal file
125
docs/cookbook/extension-cookbook.zh.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 实操手册:扩展插件形态
|
||||
|
||||
[English](extension-cookbook.md) | 中文
|
||||
|
||||
> FIXME:这篇重要指南尚未经过充分的人工设计审查;请在首次发布前完成审查。
|
||||
|
||||
针对 harness 扩展表面编写的三种插件形态,以示意性代码片段呈现(省略了 import 和辅助桩——不可直接复制运行)。完整的分步指南见[添加包(package)](./adding-a-package.md)、[添加工具](./adding-a-tool.md)和[添加 LLM(大语言模型)适配器](./adding-an-llm-adapter.md);这些插件所挂接的 seam 见 [docs/architecture.md](../architecture.md)。
|
||||
|
||||
## 工具插件
|
||||
|
||||
工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](./adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON-Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。
|
||||
|
||||
## 钩子插件(以权限门禁为例)
|
||||
|
||||
这个权限门禁是钩子插件的一个示例。它从 `tools/pre-execute` 门禁返回一个类型化的决策,用于允许或拒绝一次调用;沙箱、权限和 plan-mode 插件都可以使用该 seam。钩子插件也可以拦截其他 seam,本身并不等同于权限门禁。「原生钩子」是在拦截 seam 上运行的普通 Cordis 插件,不需要外部协议。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
declare function isAllowed(exec: ToolExecution): Promise<boolean>
|
||||
|
||||
export const name = 'permission-gate'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (!(await isAllowed(exec))) {
|
||||
return { kind: 'deny', reason: 'Denied by policy.' }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
这个 waterfall(瀑布式事件)是可重排的策略层。当不变式需要单调的最终拒绝时使用 `ctx.tools.guard()`;当插件需要包裹实际分发生命周期时(超时/重试/指标;仅 `exec.signal` 可替换)使用 `tools/execute`;显式结果变换使用 `tools/post-execute`;对不可变最终结果的受限观察使用 `tools/result`。选择规则见[添加工具指南](./adding-a-tool.md#execution-policy-and-observation)。
|
||||
|
||||
## UI 插件
|
||||
|
||||
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare function render(text: string): void
|
||||
declare function onUserInput(handler: (text: string) => void): void
|
||||
|
||||
export const name = 'my-ui'
|
||||
export const inject = ['agents']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
render(event.data.chunk.text)
|
||||
}
|
||||
})
|
||||
onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端驱动插件(外部协议桥接)
|
||||
|
||||
*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
|
||||
|
||||
`packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'my-protocol-bridge'
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// Stream every logged assistant text/reasoning delta out to the client.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'text-delta') {
|
||||
// sendToClient({ kind: 'message_chunk', text: chunk.text })
|
||||
}
|
||||
}
|
||||
})
|
||||
// Inbound "prompt": create/resume an agent and feed it; settle on turn end.
|
||||
// Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
|
||||
}
|
||||
```
|
||||
|
||||
## 可运行的组装示例
|
||||
|
||||
三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。
|
||||
|
||||
## 功能→机制映射
|
||||
|
||||
每个产品功能都映射到一个文档化扩展 seam 上的监听器——微内核声明由此可验证([微内核 RFC](../rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md))。没有任何一行修改循环本身。
|
||||
|
||||
`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。
|
||||
|
||||
| 产品功能 | 插件机制 |
|
||||
|---|---|
|
||||
| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
|
||||
| `/goal` | 通过 `agent/turn-continuation` 强制继续 + `steer()` 提醒 |
|
||||
| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 |
|
||||
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
|
||||
| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` |
|
||||
| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + 串行 `agent/pre-step` seam 上的后端(`dsh-compact-basic`);自动 = 每步之前的 token 压力检查;手动触发调用同一个 `ctx.compact` 例程([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
|
||||
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
|
||||
| AGENTS.md(根目录) | 一个读取该文件的 section provider |
|
||||
| AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` |
|
||||
| 内置工具 | `ctx.tools.register()`;schema 自动流入装配——`dsh-tool-*` 系列(bash、fs、web、subagent、todo)是已交付的示例 |
|
||||
| ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 |
|
||||
| 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 |
|
||||
| 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` |
|
||||
| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 |
|
||||
| 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` |
|
||||
| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 |
|
||||
| Plan mode | 已交付:[`@deepseek-ai/dsh-mode`](../../packages/mode/mode/README.md) — 落日志的 `mode/set` 状态、`mode:policy` 引导段、沙箱 `access` 上限(`bash/resolve-mode` clamp + 上限派生的 bash 守卫),以及经用户评审的 `exit_plan_mode` 出口 |
|
||||
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
|
||||
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
|
||||
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
|
||||
| 记忆 | section provider + 工具 |
|
||||
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
|
||||
| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` |
|
||||
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
|
||||
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
|
||||
| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47
|
||||
responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926
|
||||
@@ -1,6 +1,8 @@
|
||||
# Responding to review across a stacked PR chain
|
||||
|
||||
A wave of review comments lands across several PRs in a dependent stack (`A ← B ← C …`). This is the discipline for resolving it without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch.
|
||||
English | [中文](responding-to-pr-review-on-a-stack.zh.md)
|
||||
|
||||
Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch.
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -9,7 +11,7 @@ A wave of review comments lands across several PRs in a dependent stack (`A ←
|
||||
3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer.
|
||||
4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work.
|
||||
|
||||
## Working the wave
|
||||
## Resolve comments through the stack
|
||||
|
||||
1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause.
|
||||
2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order.
|
||||
|
||||
26
docs/cookbook/responding-to-pr-review-on-a-stack.zh.md
Normal file
26
docs/cookbook/responding-to-pr-review-on-a-stack.zh.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 在堆叠 PR 链中回应评审意见
|
||||
|
||||
[English](responding-to-pr-review-on-a-stack.md) | 中文
|
||||
|
||||
评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。
|
||||
|
||||
## 基本规则
|
||||
|
||||
1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。
|
||||
2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge <parent-branch>`,产生一个新的 merge commit)。绝不对已推送的分支做 rebase/amend/force-push:改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史。
|
||||
3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并到 `C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。
|
||||
4. **每个评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送、尚未评审的工作才可以 amend。
|
||||
|
||||
## 沿堆叠解决评审意见
|
||||
|
||||
1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。
|
||||
2. 将每个被接受的发现映射到其发起 PR,在那里修复,然后按顺序沿链向下合并。
|
||||
3. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。
|
||||
4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit。
|
||||
5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base <branch> --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 每个已修复的 PR 显示一个新 commit(PR 时间线中没有 force-push 图标)。
|
||||
- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。
|
||||
- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。
|
||||
@@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n
|
||||
|
||||
### `agent/created` — emit
|
||||
|
||||
An agent's fully composed scoped world was published in the AgentRegistry. Its session is already live in the session store. Setup is composition-only by contract; the subsequent `agent/session-start` boundary is the first supported place to inject or queue startup work. A synchronous listener throw vetoes publication and rollback emits the matching disposal edges; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced registry detach does not remove the entry immediately: removal and the paired `agent/disposed` edge wait until the creation dispatch unwinds, so no later creation listener observes a disposal that preceded its own creation callback.
|
||||
A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
@@ -23,11 +23,11 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:139`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
An agent was removed from the registry. The concrete AgentLoop lifecycle emits this only after its driver and any in-flight turn reach quiescence; a custom agent registered through the public registry owns its own driver contract, which the registry cannot infer. Ordered teardown may still be detaching the session and unwinding scoped registrations when this runs.
|
||||
An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
@@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:148`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -47,13 +47,11 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
Awaited pre-step surface-mutation checkpoint, fired once per step AFTER `turn/start` (and after the prior step closed) but BEFORE this step's `step/start` — so anything a listener appends lands OUTSIDE the step, between `turn/start`/`step/end` and the upcoming `step/start`. `step` is the number of the step about to start. The loop awaits `ctx.serial('agent/pre-step', …)` after assembling the system prompt, then opens the step and derives the request history ONCE from whatever the surface now holds. This is where compaction belongs: it mutates the session surface in place (shadowing an older range with a summary node) with its log-only `compact/*` records cleanly outside any step, and the single subsequent derive reflects the mutation — so there is no double-derive and no listener can see (or be expected to act on) an assembled `messages` array that does not exist yet.
|
||||
|
||||
Serial (awaited in registration order), not a waterfall: a listener mutates the surface as a side effect; there is nothing to transform, but the loop must wait for the mutation to complete before opening the step and deriving. Cordis `serial` bails early if a listener returns a bail value; this event is typed and documented as `void`, so listeners must not return a semantic veto value. `fullSystemPrompt` is the assembled prompt a listener needs to measure pressure (the system prompt counts toward the budget), and `sessionPrefix` is the instance's composed agent/session-prefix product for the same reason — every request carries it in front of the derived history, and it is composed BEFORE this seam fires precisely so a pressure gate counts the prefix the request will actually send (never a stale logged one). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
|
||||
Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
@@ -61,11 +59,11 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
Waterfall: decide what happens to ONE drained queued message before it becomes a `user/message` — allow (optionally rewriting the prompt bytes or attaching `additionalContext`) or block it. Fires inside the already-open turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. Call `next()` to delegate to the default (allow unchanged), or return a PromptDecision without calling `next()` to short-circuit.
|
||||
Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
@@ -73,11 +71,11 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/queued` — emit
|
||||
|
||||
A message entered the agent's inbox (queued or steering). Content and the resolved source are the detached, deeply-frozen values retained by the inbox. `source` has defaults applied and is not the caller's raw options.
|
||||
Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
@@ -85,11 +83,11 @@ A message entered the agent's inbox (queued or steering). Content and the resolv
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
Waterfall: shape the step's call configuration — model switching, sampling overrides — by returning a replacement LlmCallConfig (the frozen seed is the config the loop would otherwise use). Config is ALL a listener shapes here: every request is a pure function of the session log (the reconstructability RFC), so model-visible content flows through the log channels — `inject()`, steering, prompt-submit `additionalContext`, prompt sections via `system-prompt/assemble`, or the header-logged session prefix via agent/session-prefix — never through request mutation, and the loop records whatever config the request actually uses as a `request/header*` event before dispatch. The step's messages are already snapshotted when this fires (the `step/start` boundary): an `inject()` from a listener here lands in the log but joins the NEXT request. For surface mutation that must precede the snapshot (compaction), use agent/pre-step. Call `next()` to delegate, or return an LlmCallConfig without it to short-circuit.
|
||||
Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
@@ -97,15 +95,11 @@ Waterfall: shape the step's call configuration — model switching, sampling ove
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
Waterfall: compose the SESSION PREFIX — request-only messages placed in front of the ENTIRE derived history (directly after the provider's system slot) on every request this loop instance sends. Fired ONCE per loop instance, lazily before its first step's agent/pre-step seam — BEFORE the pre-step so a token-pressure gate (compaction) counts the prefix this instance will actually send, never a previous instance's logged one. The composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the instance's anchoring `'initial'`/`'resume'` header snapshot, and reused verbatim for every subsequent request — never recomputed mid-session, so the provider prefix cache holds by construction (a process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` snapshot). Composition runs outside the step, before the boundary snapshot: a composing listener's session append joins the CURRENT request's derived history. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded — never cached, logged, or sent — and the next turn recomposes under a live signal, so an abort-aware listener's degraded fallback cannot leak into later requests.
|
||||
|
||||
This is the home for session-stable openers the model must always see but that must NOT become durable history — a skills catalog, an AGENTS.md digest, a workspace baseline: `Session.deriveMessages()` never returns the prefix, and the header events are its only durable record, so the request stays reconstructable from the log. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter.
|
||||
|
||||
The seed is a frozen empty list; a contributing listener returns a NEW array — never an in-place push. The canonical contribution is a PREPEND, `[mine, ...await next()]`: the waterfall unwinds innermost-first (the LAST-registered listener's `next()` resolves first), so prepending yields registration order on the wire, and every plugin using it composes deterministically. The append form `[...await next(), mine]` is legal but places a contribution AFTER every later-registered plugin's — reverse registration order when all contributors append. Call `next()` to delegate, or return a list without it to short-circuit. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered through `agent.ctx` fires only for that agent's dispatches; a listener on a plain plugin context fires for every agent. The dispatch `this` is the scope carrier (`Scoped<Agent>`), built by the emitting side via `scopeTarget`/`agentEvents`.
|
||||
Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
@@ -113,11 +107,11 @@ The seed is a frozen empty list; a contributing listener returns a NEW array —
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
The agent's session lifecycle began, fired once before its first turn. `source` says why (SessionStartSource: fresh startup, a resumed persisted session, …). A pure NOTIFICATION (emit, not waterfall): a listener cannot veto by returning a decision or throwing. A listener that wants to seed context does so via `agent.inject()` (a `context/message` the first request sees). A lifecycle owner can still dispose its structural ownership edge during this notification; publication rechecks liveness and then aborts before the driver starts.
|
||||
The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
@@ -125,11 +119,11 @@ The agent's session lifecycle began, fired once before its first turn. `source`
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle off this transition, never off a status you just requested — `send()` does not flip status to `running` before it returns.
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
@@ -137,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
@@ -149,11 +143,11 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
Waterfall: override the turn-continuation decision via a typed ContinuationDecision. The loop's `defaultDecision` is `continue` when the step had tool calls or steering was injected, else `stop`. Listeners force-continue (`/goal`, `/loop` — optionally attaching a `reason` recorded as next-step steering) or force-stop (budget guards). Call `next()` to delegate to the default, or return a decision to override.
|
||||
Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
@@ -161,11 +155,11 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` waterfall, any `continue.reason`, and the pending-steering continuation override have been folded. A listener returns `{ action: 'stop' }` to make this turn terminal, or `undefined` to abstain. Terminal stop is monotonic: listener order and steering cannot resume the turn, and pending steering is discarded rather than becoming another step or turn.
|
||||
Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
@@ -173,13 +167,13 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
### `approval/request` — waterfall
|
||||
|
||||
Waterfall asking the composed answerers to decide one approval request. Dispatched only from ApprovalService.request — callers go through the service (which owns cancellation and the audit events), never through `ctx.waterfall` directly. A listener that can answer for this request's agent returns an outcome WITHOUT calling `next()` (the decision slot is single-occupancy, first listener to answer wins); a listener that does not recognize the agent MUST call `next()` so another answerer — or the fail-closed default `'unavailable'` — gets the question. Throwing is contained by the service and yields `'unavailable'`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. `req` is a readonly same-process value borrowed from the caller.
|
||||
Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
```ts cordis-catalog
|
||||
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
@@ -187,7 +181,7 @@ Waterfall asking the composed answerers to decide one approval request. Dispatch
|
||||
|
||||
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:70`](../../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
## `bash/*`
|
||||
|
||||
@@ -201,13 +195,13 @@ Waterfall around BashExecutor.resolveMode's base — the session's standing over
|
||||
|
||||
Types: [SandboxMode](../core-data-structures/sandbox.md)
|
||||
|
||||
Source: [`packages/bash/bash/src/index.ts:57`](../../packages/bash/bash/src/index.ts)
|
||||
Source: [`packages/bash/bash/src/index.ts:46`](../../packages/bash/bash/src/index.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
### `fs/edit-intent` — waterfall
|
||||
|
||||
Single-slot decision: produce the optional version guard for the next FileSystem.editText. The tool dispatches this as an unbound waterfall and supplies a default thunk returning `undefined` (unconditional edit of the current content — the bare provider; no `stat`). The `@deepseek-ai/dsh-fs-policy` policy listener returns `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset or has not observed the target. Does NOT call `next()`: one decision, first-wins (see Events.'fs/write-intent').
|
||||
Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins.
|
||||
|
||||
```ts cordis-catalog
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
@@ -215,11 +209,11 @@ Single-slot decision: produce the optional version guard for the next FileSystem
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:59`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `fs/observed` — emit
|
||||
|
||||
Record that an actor observed a target at a version, after a successful read/write/edit. Fire-and-forget (plain `emit`). A listener MUST be 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 surfaces as the tool's `isError` result, and cordis `emit` does not await listener promises — async or fallible audit/telemetry does not belong here. No listener ⇒ nothing recorded. `actor` is the opaque tool-execution context.
|
||||
Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
|
||||
|
||||
```ts cordis-catalog
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
@@ -227,11 +221,11 @@ Record that an actor observed a target at a version, after a successful read/wri
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:68`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
### `fs/write-intent` — waterfall
|
||||
|
||||
Single-slot decision: produce the write intent for the next FileSystem.writeText. The tool dispatches this as an unbound waterfall (no `this`) and supplies a default thunk returning `undefined` (unconditional create-or-overwrite — the bare provider). The `@deepseek-ai/dsh-fs-policy` policy listener returns `createIfAbsent` (unobserved actor) or `{ kind: 'replaceIfVersion', version: vObserved }` (observed) and does NOT call `next()` — one decision, not a composable chain. The slot is first-wins: the first non-`next()` decider (registration order, or `prepend`) occupies it; a second decider is a misconfiguration, not layering. `actor` is the opaque tool-execution context, never read here.
|
||||
Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers.
|
||||
|
||||
```ts cordis-catalog
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
@@ -239,7 +233,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText
|
||||
|
||||
Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:51`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
## `llm/*`
|
||||
|
||||
@@ -259,27 +253,27 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts
|
||||
|
||||
### `session/created` — emit
|
||||
|
||||
A session was created in the store. A synchronous listener throw vetoes publication and rollback emits the matching `session/disposed` edge; returned-promise rejection is observed and logged but cannot retroactively veto this synchronous boundary. A synchronous listener that requests the advanced detach does not remove the entry immediately: removal and the paired `session/disposed` edge wait until the creation dispatch unwinds. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
A previously announced session left the store. Emitted exactly once on normal detach or publication rollback, and never for a prepared/entered session whose `session/created` announcement did not begin. Listener failures (including returned-promise rejections) are logged and contained per listener so teardown always reaches quiescence. Scope-filtered dispatch uses the same owner carrier captured at entry; agent-scoped listeners hear only their own session's teardown.
|
||||
Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. The log push is the commit point; synchronous throws and returned-promise rejections from observers are logged and contained per listener, so they cannot make a committed append appear to fail or starve later listeners. The exact callback list and Cordis internal-dispatch checks resolve before the push; callbacks themselves run only after it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
@@ -287,39 +281,17 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session)` at every turn end; persistence plugins (JSONL, SQLite) drain their write-behind buffers here and on fiber dispose. Awaited (parallel), not a waterfall: every listener runs and the caller waits for all of them, but none can veto. Dispatch it through SessionStore.flush — the store owns the carrier — never via a raw `ctx.parallel`. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `skill/*`
|
||||
|
||||
### `skill/provider-added` — emit
|
||||
|
||||
A skill provider became resolvable in the `ctx.skills` registry. Consumers can observe this instead of depending on Cordis plugin load order, which is concurrent for sibling plugins.
|
||||
|
||||
```ts cordis-catalog
|
||||
'skill/provider-added'(provider: SkillProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:131`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
### `skill/provider-removed` — emit
|
||||
|
||||
A skill provider left the registry because its plugin fiber was disposed.
|
||||
|
||||
```ts cordis-catalog
|
||||
'skill/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:137`](../../packages/skill/skill/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
@@ -331,7 +303,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:108`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -341,7 +313,7 @@ A provider became resolvable in the registry.
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:66`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -351,7 +323,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:88`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -361,33 +333,29 @@ A provider established a ready child. For in-process providers, `ctx.agents.get(
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:99`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
### `system-prompt/assemble` — waterfall
|
||||
|
||||
Waterfall around prompt assembly — mutate or extend the PromptAssembly (sections + tools + variables) before it is rendered. Bound to the SystemPrompt service; call `next()` to delegate.
|
||||
|
||||
Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `context.scope` — a listener registered through `agent.ctx` fires only for that agent's assemblies; a plain plugin listener fires for every assembly (scope-less ones included, dispatched subject-less).
|
||||
|
||||
The returned assembly is authoritative. This is an expert composition seam: a listener that removes or replaces another plugin's protocol contribution owns preserving that protocol's invariants.
|
||||
Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative.
|
||||
|
||||
```ts cordis-catalog
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:49`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
### `system-prompt/change` — emit
|
||||
|
||||
A section, tool provider, or variable provider was registered or unregistered (the assembly inputs changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's.
|
||||
Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope.
|
||||
|
||||
```ts cordis-catalog
|
||||
'system-prompt/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:59`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `tools/*`
|
||||
|
||||
@@ -399,11 +367,11 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/execute` — waterfall
|
||||
|
||||
Around-dispatch waterfall wrapping the registry's core tool dispatch, between the `tools/pre-execute` gate and the `tools/post-execute` seam. A listener receives `(exec, next)`: call `next()` to delegate to dispatch (returning its ToolExecutionResult, optionally wrapped), or return a replacement result without calling `next()` to short-circuit dispatch. The base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or unknown tool) is already normalized to an `isError` result by the time a listener's `await next()` returns, so a wrapper never sees a raw throw from the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can set or replace the one mutable field, `exec.signal` (e.g. with a per-call deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the pipeline so a wrapper cannot change which tool and scope the pipeline accepted. (Cordis `next()` ignores passed arguments and re-invokes downstream with the shared payload, so a wrapper changes `exec.signal` in place rather than passing a new object to `next()`.) Multiple listeners compose by registration order — an outer one wraps the inner ones plus dispatch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` wraps only that agent's calls; a plain plugin listener wraps every call (including agent-less ones, which dispatch subject-less).
|
||||
Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
@@ -411,11 +379,11 @@ Around-dispatch waterfall wrapping the registry's core tool dispatch, between th
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:128`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/post-execute` — waterfall
|
||||
|
||||
Waterfall AFTER a tool runs — where hook plugins inspect the result and accept it (optionally REPLACING the model-facing content, and/or attaching `additionalContext` for the next request) or block it with corrective `feedback` (Claude Code's `PostToolUse`). Listeners receive `(exec, result, next)`: call `next()` to delegate to the default (accept unchanged), or return a PostToolDecision to override. Core tool dispatch runs earlier as the base `next()` of the `tools/execute` waterfall, all inside `execute`'s outer try/catch (and the tool body keeps its own inner try/catch, so a thrown tool still reaches `post-execute` as an `isError` result). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by `exec.agent` — a listener registered through `agent.ctx` fires only for that agent's calls; a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less).
|
||||
Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
@@ -423,11 +391,11 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/pre-execute` — waterfall
|
||||
|
||||
Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners receive `(exec, next)`: call `next()` to delegate to the default (allow), or return a PreToolDecision without calling `next()` to short-circuit. A `deny` skips dispatch and yields an `isError` result; the tool body never runs. Input rewrite is deliberately NOT offered here (see PreToolDecision); `ask` is serviced by the `ctx.approval` seam when one is mounted, and degrades to deny otherwise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a listener registered through `agent.ctx` fires only for that agent's calls, while a plain plugin listener fires for every call (including agent-less ones, which dispatch subject-less).
|
||||
Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
@@ -435,11 +403,11 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:101`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/result` — emit
|
||||
|
||||
Synchronous notification of the authoritative FINAL tool outcome, after the complete pre/execute/post pipeline, final lossless-JSON validation, and outer error normalization. Unlike the three waterfalls, this seam cannot transform the result: each listener receives the now-frozen execution object and a deep-frozen result snapshot; listener failures are contained and logged, and ToolRegistry.execute still returns the outcome. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`, using the same carrier as the pipeline.
|
||||
Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
||||
@@ -447,7 +415,7 @@ Synchronous notification of the authoritative FINAL tool outcome, after the comp
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:163`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `workflow/*`
|
||||
|
||||
@@ -459,7 +427,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:96`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/agent-start` — emit
|
||||
|
||||
@@ -469,7 +437,7 @@ One `agent()` call established a ready child run. Paired with Events['workflow/a
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:85`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/end` — emit
|
||||
|
||||
@@ -479,7 +447,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:106`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/log` — emit
|
||||
|
||||
@@ -489,7 +457,7 @@ The script emitted a narration line (a `log(message)` call).
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:75`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/phase` — emit
|
||||
|
||||
@@ -499,7 +467,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
### `workflow/start` — emit
|
||||
|
||||
@@ -509,7 +477,7 @@ A workflow run started — the script's meta block validated, the body about to
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## Inherited events (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:335`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -38,13 +38,11 @@ list(): Agent[]
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts)
|
||||
Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts)
|
||||
|
||||
## `ctx.approval` — `ApprovalService`
|
||||
|
||||
The `ctx.approval` service: dispatches ApprovalRequests to the `approval/request` waterfall and audits every ask/outcome pair to the requesting agent's session log. Stateless between requests — grants are returned to the caller, never stored here.
|
||||
|
||||
Owns the policy tier too (`effective = fold(the session's 'approval/policy' events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'` before dispatching any interactive answerer, a per-agent prompt section states a `'never'` policy (and only that one in prose — an `'ask'` promise could overclaim an answerer that headless compositions do not have), and an `agent/pre-step` narrator injects at most one coalesced notice when a session's effective policy moved past what the model was last told.
|
||||
Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices.
|
||||
|
||||
```ts cordis-catalog
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome>
|
||||
@@ -52,46 +50,33 @@ async request(req: ApprovalRequest): Promise<ApprovalOutcome>
|
||||
|
||||
Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md)
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:294`](../../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:229`](../../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
## `ctx.bash` — `BashExecutor` (abstract seam)
|
||||
|
||||
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
Implementations must honor these semantics:
|
||||
|
||||
- run REJECTS only for infrastructure failures (unusable workdir, missing shell, pre-aborted signal). Nonzero exits, timeout kills, and abort kills RESOLVE with a descriptive BashRunResult — reporting a failed command is the tool layer's job, not an exception.
|
||||
- start returns immediately; no timeout applies to background tasks (callers stop them via kill or the spec's AbortSignal). Completion must fire the onTaskDone listeners exactly once per task, and must NOT fire after the service is disposed.
|
||||
- readOutput is incremental: consecutive reads never re-deliver output. Implementations bound their buffers; reads that lost data flag `lossy` and point at full-stream spill files when available.
|
||||
- Disposal kills every running task and awaits their exit (no orphan processes survive `fiber.dispose()`).
|
||||
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult.
|
||||
- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.
|
||||
- BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
|
||||
- Disposal kills all running background processes and awaits their exit.
|
||||
|
||||
```ts cordis-catalog
|
||||
async resolveMode(session: Session | undefined): Promise<SandboxMode | undefined>
|
||||
abstract resolve(request: BashExecRequest): BashExecSpec
|
||||
abstract run(spec: BashExecSpec): Promise<BashRunResult>
|
||||
abstract start(spec: BashExecSpec): BashTask
|
||||
abstract get(id: BashTaskId): BashTask | undefined
|
||||
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
|
||||
abstract list(): BashTask[]
|
||||
abstract readOutput(id: BashTaskId): BashTaskRead
|
||||
abstract kill(id: BashTaskId): boolean
|
||||
onTaskDone(listener: BashTaskListener): () => void
|
||||
abstract start(spec: BashExecSpec): BashProcess
|
||||
```
|
||||
|
||||
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) · [SandboxMode](../core-data-structures/sandbox.md)
|
||||
Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [SandboxMode](../core-data-structures/sandbox.md)
|
||||
|
||||
Source: [`packages/bash/bash/src/index.ts:82`](../../packages/bash/bash/src/index.ts)
|
||||
Source: [`packages/bash/bash/src/index.ts:66`](../../packages/bash/bash/src/index.ts)
|
||||
|
||||
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
|
||||
|
||||
Abstract code-execution service. Subclass, implement run and the two descriptors, and load the subclass as a plugin — it registers as `ctx.codeRuntime` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
|
||||
- run resolves with an error FIELD for every program outcome — parse/transform failures, thrown exceptions, budget expiry, abort, substrate death (CodeRunFailure's taxonomy). It REJECTS only for caller misuse of the seam itself (e.g. a run submitted after disposal).
|
||||
- Binding calls bridge to the caller's CodeBindingFunctions verbatim; arguments and resolutions must be structured-cloneable, and the runtime treats the program as a hostile peer (arbitrary binding names are own properties, malformed traffic is rejected or ignored, never crashes the host).
|
||||
- Runs are isolated from each other: no state survives from one run to the next through the runtime.
|
||||
- Disposal reaches quiescence: in-flight runs are terminated AND awaited before the service's own teardown completes (no orphan substrate survives `fiber.dispose()`).
|
||||
Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
|
||||
@@ -99,18 +84,11 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult>
|
||||
|
||||
Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md)
|
||||
|
||||
Source: [`packages/code-runtime/code-runtime/src/index.ts:59`](../../packages/code-runtime/code-runtime/src/index.ts)
|
||||
Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/code-runtime/code-runtime/src/index.ts)
|
||||
|
||||
## `ctx.compact` — `CompactService` (abstract seam)
|
||||
|
||||
Abstract compaction service. Subclass implement the two abstract methods, and load the subclass as a plugin — it registers as `ctx.compact` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
|
||||
|
||||
Both core methods are abstract: the contract states WHAT compaction does, while the entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation.
|
||||
|
||||
Implementations MUST honor:
|
||||
|
||||
- **Surface contract**: a successful compaction shadows the compacted surface nodes with a SINGLE replacement node carrying the summary. Because `SurfaceEventType` is a closed union, that node is a `user/message` with `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are log-only (lock + provenance).
|
||||
- **Blocking**: no compaction begins while another is in progress for the same session. The recommended mechanism is the log-recorded lock — append `compact/start` before the slow work and `compact/end` after (even on failure) — so the lock is visible to replay and crash recovery.
|
||||
Abstract compaction service. Implementations own token estimation, retention, and summarization, but a successful run must replace the selected surface span with one summary node and prevent concurrent compaction of the same session. Load one implementation per context as `ctx.compact`.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||
@@ -119,20 +97,11 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com
|
||||
|
||||
Types: [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/index.ts:65`](../../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)
|
||||
|
||||
Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every backend must honor:
|
||||
|
||||
- resolve returns a stable FsTarget; the same underlying file reached by different input paths must yield the same `targetKey` so stale guards and target lookup agree across paths (e.g. through symlinks).
|
||||
- stat returns FsInfo metadata (never content) or `undefined` when the target is absent.
|
||||
- readText/streamText read the whole regular text file (the stream for large files); both own regular-file checks, UTF-8 decoding, binary/NUL rejection, and `FS_NOT_TEXT`.
|
||||
- listDir returns direct children of a directory in stable name order with resolved child targets and cheap metadata only. It never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`.
|
||||
- writeText is atomic temp-file + rename. `expected` is OPTIONAL: omit it for an unconditional create-or-overwrite (the bare-provider default), or supply a FsWriteIntent to guard the write.
|
||||
- editText verifies `expected.version` BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not `FS_EDIT_NOT_FOUND`/ `FS_AMBIGUOUS_EDIT` against newer content), then applies literal replacement and writes atomically — all inside one mutation critical section. `expected` is OPTIONAL: omit it for an unconditional edit of the current content (a missing target still reports `FS_STALE_VERSION`).
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
|
||||
@@ -146,7 +115,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F
|
||||
|
||||
Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md)
|
||||
|
||||
Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts)
|
||||
Source: [`packages/fs/fs/src/index.ts:78`](../../packages/fs/fs/src/index.ts)
|
||||
|
||||
## `ctx.llm` — `LlmService`
|
||||
|
||||
@@ -160,7 +129,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
|
||||
Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `ctx.modes` — `ModesService`
|
||||
|
||||
@@ -176,15 +145,24 @@ Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/mode/mode/src/index.ts:256`](../../packages/mode/mode/src/index.ts)
|
||||
|
||||
## `ctx.permission` — `PermissionService`
|
||||
|
||||
Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error.
|
||||
|
||||
```ts cordis-catalog
|
||||
current(events: readonly SessionEvent[]): string
|
||||
resolve(name: string): PresetSpec
|
||||
optionOf(name: string): PresetOption
|
||||
set(session: Session, name: string): void
|
||||
```
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/src/index.ts)
|
||||
|
||||
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
|
||||
|
||||
Abstract process-sandbox service. Subclass, implement confine, and load the subclass as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
|
||||
- confine either returns an argv whose runner ENFORCES the policy or fails closed — at `confine` time with SandboxUnavailableError (no backend for this host), or at EXECUTION time by the runner itself refusing to run the command (exiting without exec'ing it, identified by ConfinedArgv.runnerFailureSignatures). A silent unconfined passthrough is never a legal outcome on either path.
|
||||
- Probing exists to ARBITRATE between multiple candidate backends and may be skipped when a platform has exactly one: the sole candidate is selected directly and the runner's exec-time fail-closed refusal carries the safety property. When probing does run, it is functional (actually enforcing a profile, not a version check), at most once per provider lifetime; `confine` itself spawns nothing beyond that one-time probing.
|
||||
- The returned ConfinedArgv.enforcement states the backend's actual completeness for THIS host; `partial` is reported, never silently upgraded to `full`.
|
||||
Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
|
||||
@@ -192,18 +170,11 @@ 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:180`](../../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)
|
||||
|
||||
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Contracts every implementation MUST honor (a DB backend asserts them inside a transaction; a file backend appends at EOF):
|
||||
|
||||
- **Append-only; a crashed turn is closed, not truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. A crash can leave an unclosed final turn whose events are real (and possibly large); load preserves them and closes the orphaned turn with synthetic boundary events (see load). Only a never-fully-written torn tail fragment is discarded.
|
||||
- **Contiguous seq.** A persisted log is contiguous: `events[i].seq === i`. load rejects a parse error or a `seq` gap in the COMMITTED region (unloadable); append's first event `seq` MUST equal the backend's stored next-seq (after `load` has balanced any interrupted turn).
|
||||
- **JSON-serializable events.** `SessionEventMap` is merge-extensible, so append materializes each complete batch through the shared lossless-JSON boundary before buffering it. The public `session.events` view is immutable, but persistence still snapshots direct/replay callers at this independent trust boundary.
|
||||
- **Durability.** append returns only once the batch is durable (the file backend fsyncs; a DB commits). create MAY defer the physical write until the first append (lazy materialization).
|
||||
Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
@@ -214,7 +185,19 @@ abstract list(): Promise<SessionHeader[]>
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessionQuery` — `SessionQueryService`
|
||||
|
||||
Live-preferred logical-corpus and exact-event read service.
|
||||
|
||||
```ts cordis-catalog
|
||||
listSessions(): Promise<SessionRecord[]>
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>
|
||||
```
|
||||
|
||||
Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts)
|
||||
|
||||
## `ctx.sessions` — `SessionStore`
|
||||
|
||||
@@ -233,7 +216,7 @@ list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:564`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
@@ -246,7 +229,7 @@ async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
|
||||
```
|
||||
|
||||
Source: [`packages/skill/skill/src/index.ts:158`](../../packages/skill/skill/src/index.ts)
|
||||
Source: [`packages/skill/skill/src/index.ts:141`](../../packages/skill/skill/src/index.ts)
|
||||
|
||||
## `ctx.subagents` — `SubagentService`
|
||||
|
||||
@@ -259,11 +242,11 @@ list(): string[]
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:141`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.systemPrompt` — `SystemPrompt`
|
||||
|
||||
Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step. Registers the harness-owned `harness:identity` and `deployment:persona` sections itself (see Config.persona).
|
||||
Registry service for the prompt inputs assembled before each model step.
|
||||
|
||||
```ts cordis-catalog
|
||||
section(section: PromptSection): () => void
|
||||
@@ -272,13 +255,30 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:342`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `ctx.tasks` — `TaskService`
|
||||
|
||||
The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
|
||||
|
||||
```ts cordis-catalog
|
||||
start(spec: TaskStart): TaskId
|
||||
list(caller?: Agent): TaskSnapshot[]
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot
|
||||
read(id: TaskId, caller?: Agent): TaskRead
|
||||
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
|
||||
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>
|
||||
onTaskDone(listener: TaskDoneListener): () => void
|
||||
attachSurface(name: string): () => void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/index.ts)
|
||||
|
||||
## `ctx.tools` — `ToolRegistry`
|
||||
|
||||
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The registry contributes its schemas into the system-prompt assembly — WHICH schemas is governed by its `mode` config (see Config.mode); under a non-native mode it also owns the reserved `run_code` presentation transport and the `tools:sdk` prompt section.
|
||||
|
||||
Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a plain plugin context is GLOBAL (visible to every agent); one through a scoped context (`agent.ctx`) is filed in that scope's layer — visible to that agent alone, disposed with the scope, and SHADOWING a global tool of the same name for that agent (most-specific-wins; within one layer a duplicate name still throws). restrict masks the global layer per scope. One private visibility resolver feeds the registry's prompt contribution, get, and execute — and, under a non-native mode, the SDK section and `run_code`'s bindings — so those registry-owned presentation and dispatch paths agree. An expert `system-prompt/assemble` listener may deliberately replace the final wire composition and owns any resulting divergence.
|
||||
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
|
||||
|
||||
```ts cordis-catalog
|
||||
register(definition: ToolDefinition): () => void
|
||||
@@ -291,7 +291,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:493`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:363`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
@@ -310,7 +310,7 @@ The web access service. Registered as `ctx.web` (one instance per context).
|
||||
|
||||
Selection semantics (resolved at execution time, never order-dependent):
|
||||
|
||||
- A configured id that is registered and `status().available` → that provider.
|
||||
- A configured id that is registered and `available()` → that provider.
|
||||
- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
|
||||
- No id configured, exactly one registered usable provider → that provider.
|
||||
@@ -320,28 +320,21 @@ Selection semantics (resolved at execution time, never order-dependent):
|
||||
```ts cordis-catalog
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
|
||||
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts)
|
||||
Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts)
|
||||
|
||||
## `ctx.workflows` — `WorkflowService` (abstract seam)
|
||||
|
||||
Abstract workflow execution service. Subclass, implement start, and load the subclass as a plugin — it registers as `ctx.workflows` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
|
||||
Semantics every implementation must honor:
|
||||
|
||||
- start throws synchronously for a request that cannot begin (an unparseable script, an invalid meta block). Once it returns a WorkflowRun, `result` NEVER rejects — every failure resolves with `stopReason: 'error'` (or `'cancelled'`) — and once the run is cancelled, `result` SETTLES within the implementation's bounded grace even if the script itself never settles (a consumer awaiting `result` must never be wedged past a cancellation).
|
||||
- The `workflow/*` events fire through emitWorkflowEvent (borrowed immutable data, per-listener containment); `workflow/end` fires exactly once per started run, after `result` is settled or as it settles.
|
||||
- `dispose()` reaches quiescence within a bounded grace: it cancels, waits for the script to settle AND its started children to finish disposing, and abandons whatever is left rather than hanging its caller (the engine documents what abandonment leaves behind).
|
||||
- Runs are HOLDER-OWNED: the engine hands control (`cancel`/`dispose`) to the `start()` caller and does not track its live runs — disposing the engine's own fiber mid-run deliberately leaves those runs to their holders' teardown, so an engine reload cannot yank a run out from under the consumer awaiting it.
|
||||
Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
```
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:211`](../../packages/workflow/workflow/src/index.ts)
|
||||
Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## Inherited `ctx` members (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ Cooperative listeners usually mutate a shared request or decision object and the
|
||||
|
||||
For single-decision events, short-circuiting is the design. A policy listener can return without `next()` when it owns the decision, while a listener that only annotates or observes must delegate.
|
||||
|
||||
## Loader Configuration
|
||||
|
||||
`@cordisjs/plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted.
|
||||
|
||||
## Practical Rules
|
||||
|
||||
Encapsulate behavior into plugins: a tool pipeline event belongs to `ctx.tools`, model streaming belongs to `ctx.llm`, and live agent coordination belongs to `ctx.agents`. Prefer events for interception and policy; prefer service methods for direct capability calls.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bash Executor
|
||||
|
||||
The bash execution seam — the canonical [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) example, split across three packages: interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementation ([dsh-bash-local](../../packages/bash/bash-local), local subprocesses), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash`/`bash_output`/`bash_kill` tool schemas). Bash is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A sandboxed, containerized, or remote backend is a sibling package implementing the same interface.
|
||||
The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle.
|
||||
|
||||
Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts)
|
||||
|
||||
@@ -35,15 +35,6 @@ interface BashExecRequest {
|
||||
* uses shell syntax like `FOO=bar cmd`).
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque OWNER token for a background task — the consumer's isolation key
|
||||
* (the tool layer passes the owning agent's `session.header.id`). The
|
||||
* executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
|
||||
* the executor itself NEVER interprets it (no access policy lives in the
|
||||
* seam — that is the consumer's job). Absent for foreground runs and for an
|
||||
* ownerless background start (a non-agent caller).
|
||||
*/
|
||||
owner?: OwnerToken | undefined
|
||||
/**
|
||||
* Explicit per-call sandbox-policy input, overriding the executor's
|
||||
* configured default mode for THIS call. Never a silent default: a
|
||||
@@ -70,10 +61,8 @@ interface BashExecSpec {
|
||||
signal?: AbortSignal | undefined
|
||||
/**
|
||||
* Bytes to write to the command's stdin (then close it), carried through
|
||||
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
|
||||
* (unlike `owner`): it has no config default, so a missing one means "no
|
||||
* stdin" — the safe, ordinary case — not a silent footgun, so it stays a
|
||||
* plain optional rather than required-but-nullable (see the request field).
|
||||
* verbatim from {@link BashExecRequest.stdin}. It has no config default, so
|
||||
* a missing value means "no stdin" and remains an ordinary optional.
|
||||
*/
|
||||
stdin?: string | undefined
|
||||
/**
|
||||
@@ -85,19 +74,10 @@ interface BashExecSpec {
|
||||
*/
|
||||
env?: Record<string, string> | undefined
|
||||
/**
|
||||
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
|
||||
* being required on the resolved spec): {@link BashExecutor.resolve} carries
|
||||
* the request's `owner` through, defaulting a missing one to `undefined`. A
|
||||
* required field makes a forgotten owner a VISIBLE `undefined` rather than a
|
||||
* silently-absent property that yields an unowned (cross-session-readable)
|
||||
* task. `start()` stores it; `run()` (foreground) ignores it.
|
||||
*/
|
||||
owner: OwnerToken | undefined
|
||||
/**
|
||||
* The sandbox mode this call executes under, REQUIRED-but-nullable for the
|
||||
* same visibility reason as `owner`. A sandboxing executor's `resolve()`
|
||||
* stamps the effective mode (the request's explicit override, else its
|
||||
* configured default) so `run()`/`start()` read the spec, never the config;
|
||||
* The sandbox mode this call executes under, required-but-nullable so every
|
||||
* resolved spec states its policy. A sandboxing executor's `resolve()` stamps
|
||||
* the effective mode (the request's explicit override, else its configured
|
||||
* default) so `run()`/`start()` read the spec, never the config;
|
||||
* a non-sandboxing executor carries the request value through verbatim and
|
||||
* ignores it (`undefined` under such an executor means what its README says:
|
||||
* unconfined execution).
|
||||
@@ -106,11 +86,7 @@ interface BashExecSpec {
|
||||
}
|
||||
```
|
||||
|
||||
The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task.
|
||||
|
||||
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path.
|
||||
`stdin` and `env` are trusted in-process plugin inputs and are not exposed by `dsh-tool-bash`. The local executor scrubs ambient credentials before merging explicit caller-supplied env. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
|
||||
## Foreground runs: `BashRunResult`
|
||||
|
||||
@@ -154,9 +130,9 @@ 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 exposes its configured fallback through `BashExecutor.sandboxMode`. The tool layer folds each session's durable `bash/sandbox-mode` override and may replace it for one user-approved strictly wider call. The mode/enforcement vocabulary is owned by the [`@deepseek-ai/dsh-sandbox` seam](sandbox.md); modes govern file effects only.
|
||||
|
||||
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):
|
||||
A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel.
|
||||
|
||||
```ts type-equiv
|
||||
interface BashSandboxInfo {
|
||||
@@ -193,41 +169,42 @@ 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`
|
||||
## Background processes: `BashProcess`
|
||||
|
||||
A long-running command started with `start()` is tracked as a `BashTask`. `BashTaskStatus` is `'running' | 'completed' | 'killed'`; `done` resolves when the underlying process closes and never rejects. A sandboxing executor stamps `sandbox` once the task settles — classification runs against the settled task's collected stderr — so the field is absent while running and under an unsandboxed executor.
|
||||
`start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves.
|
||||
|
||||
```ts type-equiv
|
||||
interface BashTask {
|
||||
readonly id: BashTaskId
|
||||
readonly command: string
|
||||
status: BashTaskStatus
|
||||
interface BashProcess {
|
||||
/** Process lifecycle state (settled exactly once). */
|
||||
status: BashProcessStatus
|
||||
/** Exit code once finished (null = killed by signal / still running). */
|
||||
exitCode: number | null
|
||||
/** Terminating signal name, when signal-killed. */
|
||||
signal: NodeJS.Signals | null
|
||||
/** Resolves when the underlying process closes (never rejects). */
|
||||
/** Resolves when the underlying process closes (never rejects — a spawn failure settles as `killed` with the error on stderr). */
|
||||
readonly done: Promise<void>
|
||||
/**
|
||||
* Sandbox facts for this task's execution, stamped by a sandboxing executor
|
||||
* once the task settles and BEFORE completion listeners are notified — an
|
||||
* `onTaskDone` consumer and a `done` awaiter both see it. Denial
|
||||
* classification runs against the settled task's collected stderr, so the
|
||||
* field cannot exist earlier: absent while the task is running and under an
|
||||
* executor that does not sandbox. See {@link BashSandboxInfo} for the
|
||||
* `denied` semantics.
|
||||
*/
|
||||
/** Sandbox facts, stamped once a confined process settles. */
|
||||
sandbox?: BashSandboxInfo
|
||||
/**
|
||||
* Read output produced since the previous read (consuming — consecutive
|
||||
* reads never re-deliver). Reads that lost data flag `lossy` and point at
|
||||
* full-stream spill files when available.
|
||||
*/
|
||||
readOutput(): BashProcessRead
|
||||
/**
|
||||
* Kill the process group. Returns false when it had already finished
|
||||
* (no-op); idempotent.
|
||||
*/
|
||||
kill(): boolean
|
||||
}
|
||||
```
|
||||
|
||||
`readOutput()` returns an incremental `BashTaskRead` — the output produced since the previous read, with a `lossy` flag when truncation dropped unread bytes:
|
||||
`readOutput()` returns the incremental delta and spill recovery facts:
|
||||
|
||||
```ts type-equiv
|
||||
interface BashTaskRead {
|
||||
task: BashTask
|
||||
interface BashProcessRead {
|
||||
/** Output produced since the previous read (stderr in a marked section). */
|
||||
delta: string
|
||||
/** True when truncation dropped unread bytes the delta cannot include. */
|
||||
@@ -241,4 +218,4 @@ interface BashTaskRead {
|
||||
|
||||
## The service
|
||||
|
||||
`BashExecutor` (`ctx.bash`, abstract — defined in [`packages/bash/bash/src/index.ts`](../../packages/bash/bash/src/index.ts)) mirrors the `LlmService`/`LlmAdapter` split: `resolve` (request → spec), `run` (foreground), `start` (background), `get`/`ownerOf`/`list`/`readOutput`/`kill`, and `onTaskDone` (a `BashTaskListener` completion callback). Spawned commands get a **scrubbed env** (dropping `*KEY*`/`*SECRET*`/`*TOKEN*`) and spill files use a private 0700 dir with random names and owner-only opens — model output never gets the ambient environment or a predictable path. The implementation that provides all this is `dsh-bash-local`; the model-facing `bash`/`bash_output`/`bash_kill` schemas that call it are in `dsh-tool-bash` (and present as terminals via the [tool-presentation vocabulary](tools.md#tool-presentation-ui-vocabulary)).
|
||||
`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md).
|
||||
|
||||
@@ -39,8 +39,8 @@ interface CodeRunResult {
|
||||
* or value-less run leaves this absent.
|
||||
*/
|
||||
value?: unknown
|
||||
/** Everything the program emitted, in order (capped by the implementation). */
|
||||
logs: CodeLogEntry[]
|
||||
/** Text the program emitted, in order (capped by the implementation). */
|
||||
logs: string[]
|
||||
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
|
||||
error?: CodeRunFailure
|
||||
}
|
||||
@@ -65,18 +65,7 @@ type CodeBindingFunction = (args: unknown) => Promise<unknown>
|
||||
|
||||
## Captured output and the failure taxonomy
|
||||
|
||||
Logs arrive in emission order, attributed to their channel (the runtime's `console` shim, or stray writes to the underlying streams):
|
||||
|
||||
```ts type-equiv
|
||||
interface CodeLogEntry {
|
||||
/** Which channel produced the text. */
|
||||
source: 'console' | 'stdout' | 'stderr'
|
||||
/** The console method used; present only when `source` is `'console'`. */
|
||||
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
|
||||
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
|
||||
text: string
|
||||
}
|
||||
```
|
||||
Logs are plain strings in emission order. The runtime captures the program's console and stream output, but channel and console-method metadata are not part of the seam because consumers render only the text. Implementations cap the aggregate output and mark truncation in-band.
|
||||
|
||||
Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither:
|
||||
|
||||
|
||||
@@ -50,6 +50,6 @@ interface CompactionResult {
|
||||
|
||||
## The service
|
||||
|
||||
`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation.
|
||||
`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy.
|
||||
|
||||
Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy.
|
||||
Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details.
|
||||
|
||||
@@ -19,18 +19,19 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context |
|
||||
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
|
||||
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
|
||||
| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads |
|
||||
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles |
|
||||
| [sandbox.md](sandbox.md) | the process-confinement seam: file-effect modes, `SandboxPolicy`, `ConfinedArgv`, enforcement and fail-closed errors |
|
||||
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
|
||||
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
|
||||
| [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
|
||||
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
|
||||
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
|
||||
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
|
||||
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
|
||||
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
|
||||
|
||||
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.
|
||||
@@ -75,7 +76,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str
|
||||
|
||||
IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings.
|
||||
|
||||
The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm).
|
||||
The `Branded<B>` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package.
|
||||
|
||||
Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts)
|
||||
|
||||
@@ -83,7 +84,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index
|
||||
type Branded<B extends string> = string & { readonly [BRAND]: B }
|
||||
```
|
||||
|
||||
The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md).
|
||||
The three core IDs are `CallId`, `SessionId`, and `AgentId`. Capability packages brand their own ids too, such as `TaskId` in [tasks.md](tasks.md).
|
||||
|
||||
## Content blocks and messages
|
||||
|
||||
@@ -156,17 +157,8 @@ interface GenerateOptions {
|
||||
stop?: string[]
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* The id of the session this request belongs to — stamped by the agent loop
|
||||
* from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener
|
||||
* route a call by WHICH session issued it (the replay adapter keys its per-call
|
||||
* cursor by session, so a parent and its in-process subagent — each with its
|
||||
* own session on one context — replay from their own recorded scripts).
|
||||
*
|
||||
* Typed as `Branded<'SessionId'>` rather than importing `SessionId` from
|
||||
* `dsh-session`: that package imports `Message` from here, so importing its
|
||||
* `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a
|
||||
* real session id assigns with no cast. (A future ids package could own the
|
||||
* brand and dissolve this note.)
|
||||
* Session identity stamped by the loop for listener routing. Adapters ignore
|
||||
* it; replay uses it to keep concurrent parent and child cursors independent.
|
||||
*/
|
||||
sessionId?: Branded<'SessionId'>
|
||||
}
|
||||
@@ -201,7 +193,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
|
||||
|
||||
### The request envelope: `LlmCallConfig` and the logged header
|
||||
|
||||
Requests are built by the loop, not shaped per call: the non-history half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt, the tool schemas in the authoritative returned assembly order (initially canonicalized by dsh-system-prompt's `toolOrder` config, or lexicographically when unset), and the session prefix — is logged session state (`request/header` snapshot and delta events, [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta)), so every conversation request is a pure function of the session log ([reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). The `agent/request` waterfall receives a frozen `LlmCallConfig` seed and a listener returns a replacement to switch model or sampling; the `agent/session-prefix` waterfall — fired once per loop instance — composes the request-only messages fronting the derived history (recorded as the header's `messagePrefix`) — the loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
|
||||
The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) and the [reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
`agent/request` receives a frozen call-config seed and may return a replacement. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws.
|
||||
|
||||
On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request.
|
||||
|
||||
@@ -353,7 +347,9 @@ interface Agent {
|
||||
}
|
||||
```
|
||||
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits.
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
|
||||
|
||||
The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
|
||||
|
||||
## Interception decisions
|
||||
|
||||
@@ -396,7 +392,7 @@ type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
```
|
||||
|
||||
`agent/session-prefix` composes the session prefix — a plain `Message[]`, no dedicated payload type. Fired ONCE per loop instance, lazily on its first request: the composed list is deep-frozen, recorded as the header's `messagePrefix` ([the request envelope](#the-request-envelope-llmcallconfig-and-the-logged-header)), and placed in front of the ENTIRE derived history on every request the instance sends — the home for session-stable openers like a skills catalog or an AGENTS.md digest, never returned by `deriveMessages()`. Reuse is structural, so the prefix cannot drift mid-session (resume = a new instance = a recompose); content that changes mid-session goes through the append-only history channels instead (`agent.inject()`, `tools/post-execute` / prompt-submit `additionalContext`). Not a Decision union: the seam contributes content instead of vetoing, so the shape is the contribution itself.
|
||||
`agent/session-prefix` composes a `Message[]` once per loop instance. The deep-frozen result is recorded in the request header and prepended to every derived history, making it the home for session-stable openers. A resumed instance recomposes; mid-session changes use append-only context channels. The waterfall returns content directly because it contributes rather than decides.
|
||||
|
||||
## `ToolDefinition`
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Filesystem
|
||||
|
||||
The filesystem stack is split across four packages: a provider seam ([dsh-fs](../../packages/fs/fs), `ctx.fs`, text IO + atomic mutation primitives whose version guard is optional), a local implementation ([dsh-fs-local](../../packages/fs/fs-local), local disk), a policy plugin ([dsh-fs-policy](../../packages/fs/fs-policy), observed-state + read-before-edit + version-guarded write/edit, contributed through the `fs/*` event gate — NO service), and a consumer ([dsh-tool-fs](../../packages/fs/tool-fs), the model-facing `read`/`write`/`edit` tools, which is also the EXECUTOR — it reads/writes/edits through `ctx.fs` directly and owns read windowing). Filesystem access is an optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). A sandboxed, remote, virtual, or project-scoped backend can implement the same `FileSystem` service without changing the policy plugin or the tool schemas.
|
||||
The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas.
|
||||
|
||||
The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit.
|
||||
|
||||
|
||||
69
docs/core-data-structures/session-query.md
Normal file
69
docs/core-data-structures/session-query.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Session Query
|
||||
|
||||
Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase.
|
||||
|
||||
Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts)
|
||||
|
||||
## Logical records
|
||||
|
||||
`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation.
|
||||
|
||||
```ts type-equiv
|
||||
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
export interface SessionRecord {
|
||||
header: SessionHeader
|
||||
live: boolean
|
||||
persisted: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
export interface SessionEventRecord {
|
||||
sessionId: SessionId
|
||||
seq: number
|
||||
type: SessionEventType
|
||||
time: number
|
||||
surface: SessionEventSurface
|
||||
}
|
||||
```
|
||||
|
||||
## Bounded event reads
|
||||
|
||||
The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health.
|
||||
|
||||
```ts type-equiv
|
||||
export interface SessionEventReadRequest {
|
||||
sessionId: SessionId
|
||||
seq: number
|
||||
before?: number
|
||||
after?: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
export interface SessionEventWindow {
|
||||
session: SessionHeader
|
||||
target: SessionEvent
|
||||
events: SessionEvent[]
|
||||
startSeq: number
|
||||
endSeq: number
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata.
|
||||
|
||||
```ts type-equiv
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
```
|
||||
@@ -196,6 +196,26 @@ export interface SurfaceNode {
|
||||
}
|
||||
```
|
||||
|
||||
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
|
||||
|
||||
`foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache.
|
||||
|
||||
```ts type-equiv
|
||||
export interface SurfaceFoldReplacement {
|
||||
seq: number
|
||||
start: number
|
||||
end: number
|
||||
shadowedSeqs: number[]
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
export interface SurfaceFoldResult {
|
||||
nodes: SurfaceNode[]
|
||||
replacements: SurfaceFoldReplacement[]
|
||||
}
|
||||
```
|
||||
|
||||
## Derived history: `deriveMessages()` and `deriveEventMessage()`
|
||||
|
||||
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:
|
||||
|
||||
@@ -6,7 +6,9 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind
|
||||
|
||||
## Provider registry
|
||||
|
||||
`ctx.skills` is a multi-provider registry. Providers can represent local directories, embedded plugin data, HTTP catalogs, or another source. Provider plugins register synchronously during `apply()`; remote initialization, authentication, and discovery are awaited by `list()`. Provider objects, lookup options, and candidates are readonly same-process contracts, so the registry borrows them instead of manufacturing defensive snapshots. The registry still validates semantic fields, resolves duplicate skill names first-wins by rank/provider order/local order, and sorts the final summaries by `name` for deterministic consumers. A provider `list()` rejection is logged and skipped without caching the degraded catalog; malformed candidates still fail fast because they violate the provider contract.
|
||||
`ctx.skills` combines local, embedded, remote, or other providers. Registration is synchronous; remote initialization and discovery belong in awaited `list()`. Provider objects, options, and candidates are borrowed readonly, while semantic fields are validated.
|
||||
|
||||
Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast.
|
||||
|
||||
```ts type-equiv
|
||||
interface SkillProvider {
|
||||
|
||||
@@ -21,7 +21,7 @@ interface SubagentCapabilities {
|
||||
|
||||
## The start request
|
||||
|
||||
What a caller asks for when starting a subagent. The tool layer builds this from the model's `{ description, prompt }` plus its own config; the service validates the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The four optional fields (`outputSchema`, `maxDepth`, `toolFilter`, `persona`) each gate on the matching `SubagentCapabilities` flag — in-process backends realize `toolFilter` as a scoped `tools.restrict()` and `persona` as a scoped shadowing `deployment:persona` section, both composed in the child's creation window. `outputSchema` is an object-rooted JSON Schema within the subset `assertSupportedOutputSchema` (dsh-tools) enforces — a schema outside it is rejected loud at start; the in-process backends realize it with a forced `structured_output` capture tool (see the [driver README](../../packages/subagent/subagent-inprocess/README.md)).
|
||||
The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentStartRequest {
|
||||
@@ -64,7 +64,7 @@ interface SubagentStopReasonMap {
|
||||
|
||||
## A live run: `SubagentRun`
|
||||
|
||||
The handle the consumer holds after a provider has established a ready child. The consumer awaits `result` and MUST `dispose` on every path to cancel remaining work and reach child quiescence. `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it.
|
||||
`SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentRun {
|
||||
@@ -78,7 +78,7 @@ interface SubagentRun {
|
||||
|
||||
## The provider seam: `SubagentProvider`
|
||||
|
||||
One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance.
|
||||
Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority.
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentProvider {
|
||||
@@ -89,11 +89,11 @@ interface SubagentProvider {
|
||||
}
|
||||
```
|
||||
|
||||
`SubagentProvider.start()` and `ctx.subagents.start()` are the publication boundary: their promises fulfill only with a ready run. The service attaches result observation, emits `subagent/start`, and returns the same holder-owned run; a rejected start has already cleaned provider-owned partial resources and emits neither lifecycle event. For an in-process provider, a start listener can resolve the live child with `ctx.agents.get(info.id)`; a remote provider need not publish into the local registry. `subagent/end` carries `lastAssistantMessage` (the child's final `output`) on the settle path and reports `error` on infrastructure rejection. Both lifecycle events are observe-only emits with per-listener exception containment.
|
||||
`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions.
|
||||
|
||||
## In-process backends: depth and seed
|
||||
|
||||
The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as an ordinary `Agent` in the same application. The provider creates it directly through `parent.ctx`, passes the required signal into the core creation transaction, and delegates quiescent disposal to the returned `AgentHandle`. Provider removal prevents new starts but does not revoke an accepted run. The child receives a flat new scope rather than inheriting the parent's registrations. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:
|
||||
The spawn and fork backends create an ordinary agent through `parent.ctx`, pass cancellation into core creation, and dispose through `AgentHandle`. Provider removal blocks new starts without revoking accepted runs. Each child gets a new flat scope rather than inheriting parent registrations. Depth and fork seeding reuse existing agent and session vocabulary:
|
||||
|
||||
- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). Only `undefined` means top level; every stored present value must be a non-negative safe integer. The seam owns it — the loop neither sets nor reads it — so a nested spawn validates its parent's stored depth, rejects a derived child depth outside the safe-integer domain, and applies a defined absolute `request.maxDepth` cap to that child.
|
||||
- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
|
||||
|
||||
129
docs/core-data-structures/tasks.md
Normal file
129
docs/core-data-structures/tasks.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Background Task Runtime
|
||||
|
||||
Types shared by long-running producers, `ctx.tasks`, and task control surfaces. The [runtime RFC](../rfc/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the literal shapes from [`packages/tasks/tasks/src/types.ts`](../../packages/tasks/tasks/src/types.ts).
|
||||
|
||||
## Ids and status
|
||||
|
||||
`TaskId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskKindMap {
|
||||
bash: 'bash'
|
||||
subagent: 'subagent'
|
||||
}
|
||||
```
|
||||
|
||||
`TaskStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `TaskSnapshot.detail`.
|
||||
|
||||
## Producer contract
|
||||
|
||||
`TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskStart {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||
kind: TaskKind
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/**
|
||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||
* cancels and awaits the task. The instance must be the one currently
|
||||
* registered under its agent id. Omitting the owner creates an unowned task,
|
||||
* open to any caller until service disposal.
|
||||
*/
|
||||
owner?: Agent
|
||||
/**
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once; a throw leaves nothing registered, and the producer must clean up any
|
||||
* partially started resources.
|
||||
*/
|
||||
run(): TaskHooks
|
||||
}
|
||||
```
|
||||
|
||||
`TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskHooks {
|
||||
/**
|
||||
* Request termination. Must be synchronous, idempotent, and eventually settle
|
||||
* {@link done}; throws propagate. The optional reason is forwarded verbatim.
|
||||
*/
|
||||
cancel(reason?: string): void
|
||||
/**
|
||||
* Resolves after the producer releases its resources, not merely when work
|
||||
* finishes. Must not reject; the runtime converts a rejection to `failed`.
|
||||
* If teardown cancellation throws, the runtime may force-fail only the
|
||||
* registry record without claiming that the work stopped.
|
||||
*/
|
||||
done: Promise<TaskOutcome>
|
||||
/**
|
||||
* Consume output produced since the previous call. The producer formats
|
||||
* truncation and spill notices. Absence marks a final-output-only task; each
|
||||
* task has one consuming cursor.
|
||||
*/
|
||||
readOutput?(): string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskOutcome {
|
||||
/** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
detail?: string
|
||||
/** Final output for tasks without `readOutput`; stream tasks leave it unset. */
|
||||
output?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Consumer views
|
||||
|
||||
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state.
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskSnapshot {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
id: TaskId
|
||||
/** The producer kind the task was registered with. */
|
||||
kind: TaskKind
|
||||
/** The producer-supplied one-line label. */
|
||||
label: string
|
||||
/**
|
||||
* Owner session id used for authorization and correlation; absent for
|
||||
* unowned tasks. Completion listeners receive the exact {@link Agent}
|
||||
* separately through {@link TaskDoneListener}.
|
||||
*/
|
||||
ownerSession?: SessionId
|
||||
/** Current lifecycle state. */
|
||||
status: TaskStatus
|
||||
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while `running`/`stopping`. */
|
||||
finishedAt?: number
|
||||
/**
|
||||
* True when a kill, read, or wait has reported or committed to report the
|
||||
* terminal state. Completion surfaces suppress redundant notices when set.
|
||||
*/
|
||||
reported: boolean
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface TaskRead {
|
||||
/**
|
||||
* Stream kinds: the consuming delta since the previous read. Final-output
|
||||
* kinds: empty while live, the terminal {@link TaskOutcome.output} (or
|
||||
* empty) once settled — idempotent, never consumed.
|
||||
*/
|
||||
text: string
|
||||
/** The task's state at read time. */
|
||||
snapshot: TaskSnapshot
|
||||
}
|
||||
```
|
||||
|
||||
## Service behavior
|
||||
|
||||
[`TaskService`](../../packages/tasks/tasks/src/index.ts) provides atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the package contract and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface.
|
||||
@@ -127,7 +127,7 @@ interface ToolExecution extends ToolExecutionInput {
|
||||
}
|
||||
```
|
||||
|
||||
`ToolExecutionToken` is a compile-time opaque fresh `Symbol` at runtime; identity comparison is its only operation. Before policy runs, `ctx.tools.execute()` materializes `arguments` as detached lossless JSON, assigns the token, and deep-freezes the accepted arguments. A non-JSON value is normalized to an error before policy. `token`, `callId`, `name`, `arguments`, `agent`, and the optional `parent` token are readonly throughout the waterfalls, while an around-dispatch wrapper may add, replace, or remove only optional `signal`. After the complete pipeline the registry freezes the execution and exposes its stable identity to `tools/result` observers.
|
||||
`ToolExecutionToken` is an opaque runtime `Symbol` used only for identity comparison. Before policy, `execute()` materializes and freezes arguments, rejects non-JSON input, and assigns the token. Identity fields and the optional parent token remain readonly; only `signal` may change around dispatch. Final observers receive the frozen execution identity.
|
||||
|
||||
A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it.
|
||||
|
||||
@@ -137,7 +137,6 @@ type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
```ts type-equiv
|
||||
interface ToolExecutionResult {
|
||||
callId: CallId
|
||||
content: ContentBlock[]
|
||||
isError: boolean
|
||||
/**
|
||||
@@ -167,6 +166,8 @@ interface ToolExecutionResult {
|
||||
}
|
||||
```
|
||||
|
||||
The result carries only the outcome. Call identity remains on the immutable `ToolExecution` that accompanies it through every hook and on the durable `tool/call` / `tool/result` session events, so wrappers cannot create a second, disagreeing identity.
|
||||
|
||||
The registry materializes and freezes the final accepted result immediately before `tools/result`. Its content, structured error, additional context, and presentation metadata must round-trip losslessly through JSON; an invalid outcome becomes a JSON-safe `isError` result, so the observed live outcome is safe for the later durable `tool/result` append.
|
||||
|
||||
Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`:
|
||||
@@ -184,7 +185,9 @@ type PostToolDecision =
|
||||
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
|
||||
```
|
||||
|
||||
Call `next()` to delegate to the default (allow / dispatch / accept-unchanged), or return a decision/result to short-circuit. A `pre-execute` `deny` skips dispatch and yields an `isError` result. An `ask` resolves through the optional approval seam: only `allowed-once` proceeds, while every non-grant, missing channel/service, or agent-less request becomes a normalized denial. A registered `ToolGuard` then runs and can still impose a final denial. Input rewrite is deliberately NOT offered on `PreToolDecision` because it would desync the pre-execution audit/history/UI from what ran. A `post-execute` `accept` may replace the model-facing `content`; a `block` turns the call into an `isError` whose content is the corrective `feedback`. The synchronous `tools/result` notification then receives the frozen execution identity and a deep-frozen result snapshot after every wrapper, post decision, and outer error catch; observers cannot transform the outcome or race each other through payload mutation, and one observer failure neither changes the result nor starves peers. An unregistered tool routes through the same catch as a tool-thrown error, so both failure classes get a structured `{ name, code }` (`ToolNotFoundError` → `UNKNOWN_TOOL`) — the loop records a failed tool call instead of failing the whole turn.
|
||||
Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree.
|
||||
|
||||
Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn.
|
||||
|
||||
## The structured-output schema subset
|
||||
|
||||
@@ -229,4 +232,4 @@ How a tool wants its call shown in a UI (an editor tool-call card, a CLI log lin
|
||||
|
||||
`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd.
|
||||
|
||||
The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md).
|
||||
The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The `bash` schema and executor are on [bash.md](bash.md); generic background controls are on [tasks.md](tasks.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# User Interaction
|
||||
|
||||
The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-agent` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations.
|
||||
The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations.
|
||||
|
||||
Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts)
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ interface WebSearchRequest {
|
||||
|
||||
```ts type-equiv
|
||||
interface WebSearchResult {
|
||||
readonly providerId: string
|
||||
readonly query: string
|
||||
readonly content?: string
|
||||
readonly sources: readonly WebSearchSource[]
|
||||
readonly truncated: boolean
|
||||
@@ -49,7 +47,6 @@ interface WebSearchSource {
|
||||
```ts type-equiv
|
||||
interface WebFetchRequest {
|
||||
readonly url: string
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -57,7 +54,6 @@ HTTP status is part of the fetched resource state, not automatically a failure:
|
||||
|
||||
```ts type-equiv
|
||||
interface WebFetchResult {
|
||||
readonly providerId: string
|
||||
readonly url: string
|
||||
readonly statusCode: number
|
||||
readonly body: WebFetchBody
|
||||
@@ -73,15 +69,9 @@ type WebFetchBody =
|
||||
| { readonly kind: 'text'; readonly content: string }
|
||||
```
|
||||
|
||||
## Provider status
|
||||
## Provider availability
|
||||
|
||||
A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message.
|
||||
|
||||
```ts type-equiv
|
||||
type WebProviderStatus =
|
||||
| { readonly available: true }
|
||||
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
||||
```
|
||||
A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
|
||||
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
|
||||
|
||||
@@ -91,4 +81,4 @@ Selection never depends on registration, config, or HMR order: a capability has
|
||||
|
||||
## The service
|
||||
|
||||
`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with platform-native `fetch` at the repo's Node floor, mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets.
|
||||
`WebService` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, caps redirects, bytes, characters, and time, revalidates every same-origin redirect hop, and decodes the body; the tool owns presentation. Private-network blocking is deferred, so do not enable `web_fetch` where it can reach sensitive internal targets.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
development.md: ea5f2e5d08acbaf1dfce4661530218dbf1a051b9
|
||||
development.zh.md: 50877796f2ff47ad46cc67b35f3cd7b5704c8315
|
||||
development.md: b3c338f03f548b4de4b676850732731323d611f1
|
||||
development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c
|
||||
|
||||
@@ -19,7 +19,7 @@ Install dependencies from the repo root:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.
|
||||
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.
|
||||
|
||||
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
|
||||
|
||||
@@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook is configured in `lefthook.yml` as an early local checkpoint before review:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard.
|
||||
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs unit tests, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
|
||||
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs runtime-closure verification, unit tests, duplication detection, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
@@ -67,9 +67,7 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou
|
||||
|
||||
## CI gates
|
||||
|
||||
The keyless GitHub workflow has eight jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and three compatibility jobs run `pnpm run check:node-compat` on Node 22.19, 24, and 26. The compatibility command runs the TypeScript typecheck and a keyless workflow-workerthread source-launch smoke on every runtime, so the matrix proves that the source graph typechecks and that a real unbuilt Worker loader path executes; the other lane schedulers fan out independent gates from `package.json`: constraints, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
|
||||
|
||||
`pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=14`.
|
||||
The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.
|
||||
|
||||
## Daily commands
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
[English](development.md) | 中文
|
||||
|
||||
本文面向参与项目开发的贡献者,帮助你上手本地环境、日常工作流和 CI 流程。相关设计考量和技术取舍参见 RFC,不在这里展开。
|
||||
本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 RFC。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js 支持 22.19+ 和 24+。CI 覆盖 22.19、24、26;见 [Node engine floor RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。
|
||||
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中钉住 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,先运行 `corepack enable`。
|
||||
- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。
|
||||
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。
|
||||
- Git。
|
||||
- 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。
|
||||
- 可选:一个 DeepSeek API key,用于 REPL/ACP(Agent Client Protocol) agent(智能体)演示和真实 API 的 e2e 测试。
|
||||
|
||||
## 首次搭建
|
||||
|
||||
@@ -19,23 +19,23 @@
|
||||
pnpm install
|
||||
```
|
||||
|
||||
安装同时会运行根目录的 `postinstall` 脚本,它通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook;该包装脚本使用 lefthook 经过评审的 `--force` 模式,使已存在 `core.hooksPath` 的关联 worktree 不会让正常的 `pnpm run …` 命令失败。
|
||||
安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。
|
||||
|
||||
如果因为依赖是从缓存恢复或 `postinstall` 被跳过而缺少钩子,手动安装:
|
||||
如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:
|
||||
|
||||
```sh
|
||||
pnpm exec lefthook install --force
|
||||
```
|
||||
|
||||
新克隆后先跑一次类型检查:
|
||||
新克隆后请先运行一次类型检查:
|
||||
|
||||
```sh
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
这次首跑会构建 package/vendor 构建图,并跑根目录 no-emit `tsconfig.json` 图(覆盖 examples、tests 和 scripts)。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。
|
||||
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。
|
||||
|
||||
如果准备从新克隆或新 worktree 推送,还要构建一次:
|
||||
如果准备从新克隆或新 worktree 推送,还需要构建一次:
|
||||
|
||||
```sh
|
||||
pnpm run build
|
||||
@@ -45,31 +45,29 @@ pnpm run build
|
||||
|
||||
## 环境变量
|
||||
|
||||
真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 读取凭证:
|
||||
真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:
|
||||
|
||||
```sh
|
||||
DEEPSEEK_API_KEY=sk-...
|
||||
DEEPSEEK_BASE_URL=https://... # optional
|
||||
```
|
||||
|
||||
`DEEPSEEK_BASE_URL` 可选,默认为公开 API。绝不要提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。
|
||||
`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。
|
||||
|
||||
## Git 钩子
|
||||
|
||||
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行单元测试、快照测试、build、module graph 新鲜度,以及 `pnpm run hygiene` 和 `pnpm run doc-sync` 的成员门禁。
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest(元数据清单)守卫;
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene` 与 `pnpm run doc-sync` 的各成员门禁。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。
|
||||
|
||||
这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上跑兼容性矩阵。
|
||||
这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
keyless GitHub 工作流有八个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,三个兼容性 job 在 Node 22.19、24 和 26 上运行 `pnpm run check:node-compat`。兼容性命令会在每个运行时上运行 TypeScript 类型检查和 keyless 的 workflow-workerthread 源码启动冒烟测试,因此该矩阵既证明源码图能通过类型检查,也会实际执行一条未构建的 Worker loader 路径;其他 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
|
||||
|
||||
`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。
|
||||
keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。
|
||||
|
||||
## 日常命令
|
||||
|
||||
@@ -101,7 +99,7 @@ pnpm run verify-node-next-types # fail if built declarations are not NodeNext-c
|
||||
pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check
|
||||
```
|
||||
|
||||
改动 package 的公开行为时,在同一个变更里更新相关 README 或 JSDoc。`pnpm run doc-sync` 能抓住被检查的 TypeScript 片段、生成文档新鲜度、markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。
|
||||
修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。
|
||||
|
||||
## 演示
|
||||
|
||||
@@ -125,24 +123,24 @@ pnpm run demo:acp
|
||||
|
||||
## TODO 标记
|
||||
|
||||
用三种注释标签之一标记代码中的已知问题,按紧急程度排序:
|
||||
请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:
|
||||
|
||||
- `FIXME`——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 `FIXME` 出门。
|
||||
- `TODO`——应当尽快修复的问题,等资源到位就处理。
|
||||
- `XXX`——也许某天会修的问题;优先级最低,不作承诺。
|
||||
- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;
|
||||
- `TODO`:应当尽快修复的问题,等资源到位即可处理;
|
||||
- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。
|
||||
|
||||
选择与紧急程度匹配的标签,让扫代码的人一眼分清「发布阻塞」和「有空再说」。
|
||||
请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。
|
||||
|
||||
## 逐字记录类型(`ts type-equiv`)
|
||||
|
||||
[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,把它围栏成 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
|
||||
[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:
|
||||
|
||||
```json
|
||||
{ "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }
|
||||
```
|
||||
|
||||
`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型,门禁会失败直到你更新粘贴内容;当你增删一个块,在同一个变更里更新 manifest。
|
||||
`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。
|
||||
|
||||
## 架构上下文
|
||||
|
||||
改动 `packages/` 下的任何东西之前先读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam(扩展点)与显式扩展点构建。
|
||||
在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。
|
||||
|
||||
@@ -3,52 +3,50 @@
|
||||
|
||||
# Event Producer And Consumer Matrix
|
||||
|
||||
This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.
|
||||
This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`mode`](../packages/mode/mode), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `bash/resolve-mode` | `waterfall` | [`packages/bash/bash/src/index.ts:57`](../packages/bash/bash/src/index.ts) | [`bash`](../packages/bash/bash) (`waterfall`) | [`mode`](../packages/mode/mode) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`mode`](../packages/mode/mode), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `bash/resolve-mode` | `waterfall` | [`packages/bash/bash/src/index.ts:46`](../packages/bash/bash/src/index.ts) | [`bash`](../packages/bash/bash) (`waterfall`) | [`mode`](../packages/mode/mode) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`mode`](../packages/mode/mode) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:128`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:101`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:96`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:85`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:106`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:75`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:99`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`mode`](../packages/mode/mode) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:89`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:98`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:80`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`mode`](../packages/mode/mode) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
## Non-harness or undeclared event strings seen in package source
|
||||
|
||||
@@ -56,4 +54,4 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| --- | --- | --- |
|
||||
| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) |
|
||||
|
||||
Maintenance mode: hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`.
|
||||
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 81e958c8ae9552222f27b0f9674d4fa0ff2a481c
|
||||
README.zh.md: 3764a9ade69f3ad99e36ccccbaed1d19d8bbb1f0
|
||||
README.md: 17bb1eeb67b4f5119a698fca23f12490c9378a7f
|
||||
README.zh.md: c957a82bf420a942e2249942a2d9afc54ad950cf
|
||||
|
||||
@@ -17,15 +17,16 @@ This repo's documentation is read by people and agents both inside and outside t
|
||||
|
||||
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.
|
||||
- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.
|
||||
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).
|
||||
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).
|
||||
|
||||
## The gate: verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically:
|
||||
|
||||
1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.
|
||||
2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher.
|
||||
2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.
|
||||
3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.
|
||||
4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named RFCs merge bilingual from birth.
|
||||
|
||||
`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok — and is the work list for translation batches. It never fails; it reports.
|
||||
|
||||
@@ -39,12 +40,13 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
|
||||
|
||||
**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):
|
||||
|
||||
- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.
|
||||
- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/config-catalog.md`, `docs/persistence-catalog.md`, and `docs/module-graph.md` — generated files; their generators emit English only today, so a hand-written translation would go stale on every regeneration. The planned follow-up is to teach the generators to emit Chinese alongside English, at which point these leave the exclusion list.
|
||||
- `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`.
|
||||
- `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction.
|
||||
- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
|
||||
- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
|
||||
|
||||
**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Pairs land in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any pair that already exists is held to the full contract regardless of the list. Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it.
|
||||
**Rollout**: a date-named document (`yyyy-mm-dd-*.md`, i.e. an RFC) dated on or after the manifest's `requiredSince` cutoff must merge with its pair. Earlier dates are backlog, including files created on the cutoff's eve. An RFC filename records its first-proposed date, so backdating past the cutoff is a review-visible violation. The manifest's `required` list is the current enforcement frontier, not the goal of full coverage. Translation batches add paths to `required`, ratcheting the gate forward. Unlisted documents remain visible in `--list`, while every existing pair is governed by the full contract. Because later edits must update both sides, expand `required` only as fast as translation review can support.
|
||||
|
||||
## Division of labor
|
||||
|
||||
Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate exists so that neither the agent nor the reviewer has to remember the contract: pair completeness, consistency, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point.
|
||||
Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the canonical rules into either direction and strictly parses the three-field XML response, while `verify-translation-prompt` exercises both render directions, the checked-in example, and the CDATA split rule in `doc-sync`.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。进仓的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。
|
||||
本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。仓库内置的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。
|
||||
|
||||
## 配对契约
|
||||
|
||||
- **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 RFC 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。
|
||||
- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对整体合入:PR 永远不会只带一种语言而缺其余两个文件。
|
||||
- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对整体合入:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。
|
||||
- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:
|
||||
|
||||
```yaml
|
||||
@@ -17,15 +17,16 @@
|
||||
|
||||
用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后,`pnpm run verify-translation-pairing --write` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。
|
||||
- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
|
||||
- **结构与另一侧一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||
- **结构与另一侧一一对应。**标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||
|
||||
## 门禁:verify-translation-pairing
|
||||
|
||||
`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约:
|
||||
`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约:
|
||||
|
||||
1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
|
||||
2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。
|
||||
2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
|
||||
3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。
|
||||
4. 凡文件名符合 `yyyy-mm-dd-*.md` 且日期不早于 manifest(元数据清单)中 `requiredSince` 分界日期的文档,都必须有完整配对——新建的日期命名 RFC 从创建起便须配齐中英文。
|
||||
|
||||
`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态——missing、out-of-sync 或 ok——是翻译批次的工作清单。它从不失败;它只报告。
|
||||
|
||||
@@ -39,12 +40,13 @@
|
||||
|
||||
**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
|
||||
|
||||
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。
|
||||
- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/config-catalog.md`、`docs/persistence-catalog.md` 与 `docs/module-graph.md`——生成文件;生成器目前只输出英文,手写译文在每次重新生成时必然陈旧。计划中的后续工作是让生成器同时输出中文,届时这些文件移出排除清单。
|
||||
- `docs/AGENTS.md`——agent 指令,与根 `AGENTS.md` 一样只以英文维护。
|
||||
- `docs/i18n/terminology.md`——术语表本身即是双语构造。
|
||||
- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md)——二者本身即为中英对照文档。
|
||||
- [translation-prompt.md](translation-prompt.md)——自动翻译流水线的 prompt 模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
|
||||
|
||||
**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。配对按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的配对无论在不在清单里都按完整契约检查。给一篇文档配对是一份承诺:此后对任一侧的每次修改都必须带上另一侧,所以边界的扩张要跟上翻译评审的实际投入节奏,不要抢在前面。
|
||||
**推进**:以日期命名的文档(`yyyy-mm-dd-*.md`,即 RFC),只要标注日期等于或晚于 manifest 的 `requiredSince` 分界日期,合入时就必须配齐双语文件。更早日期的文件属于 backlog(待翻清单),包括分界前夜创建的文件。RFC 文件名记录首次提出日期,因此倒填日期绕过分界属于评审可见的违规。manifest 中的 `required` 列表是当前执行红线,并非全量覆盖这一最终目标。翻译批次将路径加入 `required`,使门禁只向前收紧。未列入的文档仍可通过 `--list` 查看,而任何已存在的配对都受完整契约约束。后续修改必须同步更新两侧,因此 `required` 的扩展速度不能超过翻译评审的承载能力。
|
||||
|
||||
## 分工
|
||||
|
||||
这里的对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对完整性、一致性和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。
|
||||
对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 生成,再由人评审:在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁负责检查配对是否完整、记录的 hash、语言切换行以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。prompt 契约也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把权威规则渲染到英译中或中译英的 prompt 中,并严格解析包含三个字段的 XML 响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向、仓库内示例与 CDATA 拆分规则。
|
||||
|
||||
87
docs/i18n/style-samples.md
Normal file
87
docs/i18n/style-samples.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 翻译语体样例(style samples)
|
||||
|
||||
本文件是翻译语体的校准锚点:每组样例是一段英文原文与一段人工定稿的中文译文,覆盖本仓库文档的主要文体。**译文的语体以这些样例为准**——文体样例的效力高于对语气的文字描述,但术语表、忠实性与结构规则仍然优先。翻译或评审时对照最接近的文体样例。本文件中英对照、自成双语,不参与配对(见 [README.md](README.md) 排除清单)。
|
||||
|
||||
维护方式:人工评审校准出新的金标段落后追加到对应文体;发现语义、结构或术语错误时直接修正。新增或修正样例都需经过 PR 评审。
|
||||
|
||||
## ① 架构叙述
|
||||
|
||||
> This document describes the architecture of the DeepSeek Harness — the foundation of **DeepSeek Code**. The governing principle, from the microkernel design discussion: **everything is a plugin**. The core is deliberately tiny — a handful of abstract services plus one concrete loop plugin (`dsh-agent-loop`) — and every product feature is a plugin against the extension surface described here, without modifying the loop.
|
||||
|
||||
本文介绍 DeepSeek Harness 整体架构,它是 **DeepSeek Code** 的底层基座。微内核设计讨论中确立了核心设计准则:**一切皆插件**。内核刻意做得极精简,仅包含少量抽象服务,外加一个实体循环插件 `dsh-agent-loop`。所有产品功能均基于本文定义的扩展接口开发为独立插件,无需改动主循环逻辑。
|
||||
|
||||
> Dependency rule: extension plugins depend on interfaces, never on `dsh-agent-loop` (the loop is swappable); the sanctioned exception is the composition bundle `dsh-agent-spine-demo`, whose job is assembling the concrete spine.
|
||||
|
||||
依赖约束规范:各类扩展插件仅依赖抽象接口,严禁直接依赖 `dsh-agent-loop`(该主循环支持替换实现);唯一允许的特例是组合包 `dsh-agent-spine-demo`,它的职责是组装整套实体主干。
|
||||
|
||||
> This document covers **behavior**; type shapes live in [core-data-structures/](../core-data-structures/core.md), the per-event/service reference in the [generated catalog](../cordis-catalog/events.md), per-package contracts in the package READMEs ([map](../../packages/README.md)).
|
||||
|
||||
本文档描述整体行为逻辑;类型定义存放于 [core-data-structures/](../core-data-structures/core.md);各类事件、服务的详细参考见[生成目录](../cordis-catalog/events.md);各包(package)的对外契约写在相应的 README 中([索引](../../packages/README.md))。
|
||||
|
||||
## ② 防御模式规则
|
||||
|
||||
> Hard-won bug-class rules: each pattern below is a class of defect that actually shipped or nearly shipped here, stated as the rule that prevents its recurrence. Read this before writing lifecycle, concurrency, subprocess, or teardown code.
|
||||
|
||||
这些都是踩坑总结得出的缺陷分类规范:下文每种范式都对应一类曾上线、或险些流入线上的问题,每条规范旨在杜绝同类问题复现。编写生命周期、并发、子进程、资源销毁相关代码前,请务必阅读本文档。
|
||||
|
||||
> **Dispose must reach quiescence, not just request it** — A teardown that issues kills/aborts but returns before the work stops leaves orphans. Make cleanup async and await the children's exit (kill → await `done`), and close listener/notification registries BEFORE killing so late completions stay silent. Tests prove disposal waited (pid gone right after `await fiber.dispose()`), not merely that the process eventually dies.
|
||||
|
||||
**dispose(资源释放)必须等待所有任务完全停稳,不能仅下发终止指令就返回**:如果清理过程只发出终止或中断信号,却不等任务停止就返回,就会留下孤儿进程。清理应采用异步方式,等待所有子任务彻底退出(先发出终止信号,再等待退出);发出信号前应先关闭监听器与通知注册表,使延迟到达的完成事件不再触发通知。测试要证明 dispose 的确等到清理完成:执行完 `await fiber.dispose()` 后进程 PID 立即消失,不能只检查进程最终会自行消亡。
|
||||
|
||||
> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns.
|
||||
|
||||
**异步状态不等同于同步瞬时状态**:调用 `agent.send()` 不会在返回前同步更新状态;后台任务的完成时间与轮次边界存在竞态;`reader.close()` 既会在读到文件末尾时触发,也会在资源释放时触发。切勿把刚刚发起的状态变更当成已经生效,据此控制流程;生命周期逻辑应以实际触发的事件和已完成的 promise(`agent/status`、`task.done`)为准,并观察完整的状态变化(先 `running`,再 `idle`),不要根据操作次数推断操作与轮次一一对应。
|
||||
|
||||
## ③ 测试政策清单
|
||||
|
||||
> **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped.
|
||||
|
||||
覆盖率门禁(`pnpm run test:coverage`):作为合入门禁校验,要求 `packages/*/*/src` 目录下每个文件行覆盖率达到 100%。未覆盖代码行大多是无用死代码,门禁标记这类代码是提示删除,而非单纯补充测试。行覆盖率是必要条件,但远不充分:它仅能证明代码被执行过,无法保证功能符合线上预期。
|
||||
|
||||
> We are DeepSeek — do not ration real-API tests. A no-key test proves the plumbing; only a with-key run proves the agent works against a real model. Write many: real prompts that write files, multi-turn conversations, tool use, cancellation mid-stream. Cheapest and highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot. The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal.
|
||||
|
||||
我们是 DeepSeek:真实接口相关测试不得刻意缩减用例数量。无密钥测试仅能验证底层通路;只有携带有效密钥执行的用例,才能确认 agent(智能体)可正常对接真实模型。请大量编写此类测试:包含文件写入类真实提示词、多轮对话、工具调用、流式中途取消等场景。
|
||||
|
||||
成本最低、收益最高的是**冒烟测试**:拉起完整真实示例,发送一条真实提示,并检查文件、进程等外部可观察结果。这类用例能捕获一类问题——单元测试全部绿灯,但产品实际运行故障,单靠 mock 完全无法发现这类缺陷。
|
||||
|
||||
自带自动跳过逻辑,仅用于保障无密钥 CI 环境、无权限贡献者不会被流程拦截,不代表可以以此为由削减真实接口测试投入。
|
||||
|
||||
> **Prefer the real implementation over a mock** — Mock only the genuinely expensive or non-deterministic boundary (the LLM adapter, the network, the clock); keep everything downstream real. A hand-rolled stand-in proves the bridge moves bytes, not that the shipping tool behaves as asserted — the two drift while the test stays green.
|
||||
|
||||
**优先使用真实实现,而非 mock 替身**——仅对开销极大、结果不确定的边界模块做 mock(LLM(大语言模型)适配器、网络、时钟),其余下游组件全部使用真实实现。手写的 mock 替身只能验证数据通路能传输字节,无法保证线上工具符合预期逻辑;长期下来业务逻辑与 mock 实现会出现偏差,但测试仍会显示通过。
|
||||
|
||||
## ④ 机制描述
|
||||
|
||||
> Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files.
|
||||
|
||||
系统采用文件 blob hash 而非 commit hash 记录状态。同一 PR 内修改文件时,可通过 `git hash-object foo.md` 直接算出对应 blob hash,仅对比文件内容即可判断双语文档是否同步。通过记录的 blob hash,可使用 `git cat-file -p <hash>` 还原上次确认对齐时两侧的原文。当双语文档不一致时,只需对比修改版本与上次确认版本的差异,最小幅度同步修改另一侧译文,无需全文重新翻译。
|
||||
|
||||
## ⑤ 政策声明
|
||||
|
||||
> The gate's limit, stated plainly: a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound. It checks hashes and shape; it cannot judge whether the two sides actually say the same thing — that is the reviewer's half of the contract. A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.
|
||||
|
||||
门禁的边界很明确:通过门禁只说明两侧文件当前的 blob hash 与伴随记录吻合,并且结构签名一致,也就是说,这组内容曾被确认一致;它不代表这次确认可靠。门禁无法判断两种语言是否真正表达了相同的意思;这部分契约要由评审人把关。即使译文粗糙、表意有误,重新记录配对后仍能通过门禁,但绝不能通过人工评审。
|
||||
|
||||
## ⑥ RFC 论证
|
||||
|
||||
> Comparing git timestamps of the pair (no record) — rejected: formatting-only edits would false-positive, and a counterpart committed after an unrelated edit would false-negative; content identity is the only signal that means what the gate claims.
|
||||
|
||||
对比双语文件的 git 时间戳(无记录方案)——不予采纳:仅调整格式的改动会触发误报,无关修改后再提交译文又会造成漏检。只有基于内容本身的标识(每侧文件的 blob hash 与伴随记录比对),才能承载门禁所声称的语义。
|
||||
|
||||
## ⑦ 推进策略(长段拆分示范)
|
||||
|
||||
> **Rollout**: date-named RFCs don't wait for a batch — one dated on or after the manifest's `requiredSince` cutoff must merge with its pair, so each new date-named RFC is bilingual from birth. For the back-catalog, the `required` list in the manifest is the enforcement frontier, not the goal. […] Pairing a document is a commitment: every later edit to either side must carry the counterpart along, so grow the frontier at the pace translation review is actually resourced, not ahead of it.
|
||||
|
||||
**推进**:日期命名的 RFC 无需等待批量翻译。只要文件名中的日期不早于 manifest(元数据清单)的 `requiredSince` 分界日期,合入时就必须配齐中英文,因此此类 RFC 从创建起就要求双语齐备。对于存量文档,manifest 中的 `required` 列表只是当前的执行红线,并非最终目标。(……)一旦文档完成配对,后续修改任一侧都必须同步更新另一侧。因此,应根据实际可投入的翻译评审能力逐步扩展执行红线,不能超前。
|
||||
|
||||
## 从样例提炼的要点
|
||||
|
||||
- 语体是规范制度文:完整主谓、确定语气;不口语化,也不学术腔。
|
||||
- 给句子补显式执行主体:英文的被动句和抽象主语,中文写成「系统/门禁/工具/评审人」做主语。
|
||||
- 用中文工程惯用语替换直译:false positive/negative→误报/漏检、enforcement frontier→执行红线、ratchet→只向前收紧不倒退放宽、reviewable act→评审凭证。
|
||||
- 隐喻本地化而非移植:bilingual from birth→从创建起就要求双语齐备;grandfathered→历史存量遗留。
|
||||
- 类别名词说中文并在首现括注英文:实操手册(cookbook)、事故复盘(postmortem);指目录或路径时保留代码体英文。
|
||||
- 长段按语义单元拆段,一段一件事;名词短语展开为动词句。
|
||||
- 母语重写不等于删减:原文每个语义成分都要落地。
|
||||
- 样例与 [terminology.md](terminology.md) 冲突时,以术语表为准:收录样例前按表修正术语(例如 agent、mock、LLM 保留英文,cancellation 译「取消」)。
|
||||
- 代码体标识符(事件名 `agent/status`、状态值 `running`、包名 `dsh-bash-local` 等)在译文中保留 code span 原文,不得口语化改写——这是行文规则的硬边界,Pass 2 逐句核验的重点。
|
||||
@@ -2,142 +2,175 @@
|
||||
|
||||
本表约定本仓库的中英术语统一译法。
|
||||
|
||||
| English | 中文 | 备注 |
|
||||
|---|---|---|
|
||||
| ACP | ACP | 首次出现可写:ACP(Agent Client Protocol) |
|
||||
| AI | AI | 首次出现可写:人工智能(AI) |
|
||||
| API | API | |
|
||||
| CI | CI | |
|
||||
| CLI | CLI | 首次出现可写:命令行界面(CLI) |
|
||||
| Cordis | Cordis | 保留英文 |
|
||||
| Function Calling | Function Calling | 首次出现可写:Function Calling(函数调用) |
|
||||
| HMR | HMR | 首次出现可写:热模块替换(HMR) |
|
||||
| JSON Schema | JSON Schema | |
|
||||
| JSONL | JSONL | |
|
||||
| lint | lint | |
|
||||
| loader | loader | |
|
||||
| LLM | LLM | 首次出现可写:大语言模型(LLM) |
|
||||
| MCP | MCP | |
|
||||
| PR | PR | 首次出现可写:PR(pull request) |
|
||||
| RAG | RAG | 首次出现可写:检索增强生成(RAG) |
|
||||
| SDK | SDK | |
|
||||
| SSE | SSE | 首次出现可写:SSE(Server-Sent Events) |
|
||||
| VFS | VFS | 首次出现写:虚拟文件系统(VFS) |
|
||||
| agent | agent | 首次出现可写:agent(智能体) |
|
||||
| agent loop | agent loop | |
|
||||
| backlog | backlog | 双语翻译语境指待翻清单 |
|
||||
| blob hash | blob hash | git 对象哈希;`git hash-object` 的结果 |
|
||||
| doc-sync | doc-sync | 仓库门禁名,保留英文 |
|
||||
| e2e | e2e | |
|
||||
| fiber | fiber | 首次出现可写:fiber(插件运行时) |
|
||||
| fixture | fixture | 指测试前置数据或环境 |
|
||||
| fork | fork | 保留英文 |
|
||||
| harness | harness | 保留英文 |
|
||||
| manifest | manifest | 描述模块或工具元数据的文件 |
|
||||
| monorepo | monorepo | |
|
||||
| schema DSL | schema DSL | |
|
||||
| schema | schema | 保留英文 |
|
||||
| seam | seam | 首次出现可写:seam(扩展点) |
|
||||
| skill | skill | 首次出现可写:skill(技能) |
|
||||
| spawn | spawn | 保留英文 |
|
||||
| steering | steering | 首次出现可写:steering(中途引导) |
|
||||
| subagent | subagent | 首次出现可写:subagent(子 agent) |
|
||||
| transcript | transcript | 首次出现可写:transcript(文本记录);指会话渲染给用户或编辑器的完整文本,区别于事件日志(event log) |
|
||||
| waterfall | waterfall | 首次出现可写:waterfall(瀑布式事件) |
|
||||
| worktree | worktree | git 工作区概念,保留英文 |
|
||||
| wire format | 协议格式 | 首次出现可写:协议格式(wire format) |
|
||||
| adapter contract | 适配器契约 | 首次出现可写:适配器契约(adapter contract) |
|
||||
| adapter | 适配器 | |
|
||||
| append-only | 仅追加 | |
|
||||
| artifact | 产物 | |
|
||||
| block | 块 | |
|
||||
| background task | 后台任务 | |
|
||||
| backend | 后端 | |
|
||||
| build target | 构建目标 | |
|
||||
| capability | 能力 | |
|
||||
| cancel | 取消 | |
|
||||
| checkpoint | 检查点 | |
|
||||
| chunk | 分片 | |
|
||||
| compaction | compaction | 首次出现可写:compaction(上下文压缩);正文优先保留英文 |
|
||||
| consumer | 消费方 | |
|
||||
| content block | 内容块 | |
|
||||
| config | 配置 | |
|
||||
| context | 上下文 | |
|
||||
| context compaction | 上下文压缩 | 首次出现可写:上下文压缩(context compaction) |
|
||||
| contract | 契约 | 如:配对契约(pairing contract);另见 adapter contract |
|
||||
| coverage | 覆盖率 | |
|
||||
| crash recovery | 崩溃恢复 | |
|
||||
| dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 |
|
||||
| deploy root | 部署根目录 | |
|
||||
| durability | 持久性 | |
|
||||
| enforcement frontier | 强制边界 | i18n 机制词:manifest `required` 清单所划的门禁生效范围 |
|
||||
| event log | 事件日志 | |
|
||||
| event | 事件 | |
|
||||
| event stream | 事件流 | |
|
||||
| event-sourced | 事件溯源 | DDD 社区通行译法 |
|
||||
| executor | 执行器 | |
|
||||
| extension | 扩展 | |
|
||||
| fail-fast | 快速失败 | |
|
||||
| fenced code block | 围栏代码块 | MDN 中文同译 |
|
||||
| finish reason | 结束原因 | |
|
||||
| fingerprint | 指纹 | i18n 机制词:`.zh.md` 首行记录英文源 blob hash 的 `i18n-source` 注释 |
|
||||
| foreground run | 前台运行 | |
|
||||
| freshness | 新鲜度 | MDN HTTP 缓存中文同译(freshness lifetime → 新鲜度生命周期);指译文相对英文源的同步状态 |
|
||||
| hook | 钩子 | |
|
||||
| implementation | 实现 | |
|
||||
| inference | 推理(inference) | 每次提及时保留英文括注,避免与 reasoning 混淆 |
|
||||
| info string | 信息字符串 | CommonMark 中文同译;代码围栏 ``` 之后的语言标注 |
|
||||
| injection | 注入 | |
|
||||
| interface | 接口 | |
|
||||
| integration | 集成 | |
|
||||
| language switcher | 语言切换行 | i18n 机制词:双语配对文件顶部的互链行 |
|
||||
| memory | memory / 记忆 / 内存 | 按上下文区分:agent memory 译为“记忆”;resource/memory usage 译为“内存” |
|
||||
| message | 消息 | |
|
||||
| mod | 模组 | 区别于 module(模块);plugin 译作「插件」 |
|
||||
| model provider | 模型提供方 | |
|
||||
| module | 模块 | |
|
||||
| orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 |
|
||||
| pairing | 配对 | |
|
||||
| peer dependency | 对等依赖 | 首次出现写:对等依赖(peer dependency) |
|
||||
| permission | 权限 | |
|
||||
| persistence | 持久化 | |
|
||||
| pipeline | 流水线 | |
|
||||
| plugin | 插件 | mod 对应“模组” |
|
||||
| prompt | 提示词 | |
|
||||
| provider | 提供方 | |
|
||||
| provider-neutral | 提供方无关 | |
|
||||
| quality gate | 质量门禁 | |
|
||||
| registry | 注册表 | |
|
||||
| reasoning | 推理(reasoning) | 需要和 inference 区分时保留英文括注;`reasoning_content` 译为“思考内容” |
|
||||
| replay | 回放 | |
|
||||
| resume | 恢复 | |
|
||||
| runtime | 运行时 | |
|
||||
| sandbox | 沙箱 | |
|
||||
| service | 服务 | |
|
||||
| serving surface | 对外服务接口 | |
|
||||
| session | 会话 | |
|
||||
| session event | 会话事件 | |
|
||||
| smoke test | 冒烟测试 | |
|
||||
| snapshot | 快照 | |
|
||||
| spine | 主干 | |
|
||||
| staged | 暂存 | git 官方中文同译 |
|
||||
| stale | 陈旧 | MDN HTTP 缓存中文同译,与「新鲜(fresh)」成对;门禁输出保留英文 `stale`;expired 才译「过期」 |
|
||||
| step | 步骤 | |
|
||||
| stream | 流 | |
|
||||
| streaming | 流式输出 | |
|
||||
| structural signature | 结构签名 | i18n 机制词:配对门禁比对的有序结构序列 |
|
||||
| system prompt | 系统提示词 | |
|
||||
| taxonomy | 分类体系 | |
|
||||
| token usage | token 用量 | |
|
||||
| thinking | thinking | API 字段保留;模型模式译为“思考” |
|
||||
| tool | 工具 | |
|
||||
| tool call | 工具调用 | |
|
||||
| tool result | 工具结果 | |
|
||||
| tool schema | 工具 schema | |
|
||||
| toolkit | 工具包 | |
|
||||
| turn | 轮次 | |
|
||||
| typecheck | 类型检查 | |
|
||||
| vocabulary | 词汇 | |
|
||||
| wheel | wheel 包 | |
|
||||
| workflow | 工作流 | |
|
||||
| wrapper | 包装层 | |
|
||||
**通用规则:**
|
||||
- "中文"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。
|
||||
- 首次出现按"首次出现"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。
|
||||
- "不要译作"列为严格禁止的译法。
|
||||
- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。
|
||||
|
||||
## 缩写类(中英文文本中均使用缩写)
|
||||
|
||||
| English | 中文 | 首次出现 | 不要译作 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| ACP | ACP | ACP(Agent Client Protocol) | | |
|
||||
| AI | AI | AI(人工智能) | | |
|
||||
| API | API | | | |
|
||||
| CI | CI | | | |
|
||||
| CLI | CLI | CLI(命令行界面) | | |
|
||||
| e2e | e2e | | | |
|
||||
| HMR | HMR | HMR(热模块替换) | | |
|
||||
| JSON Schema | JSON Schema | | | |
|
||||
| JSONL | JSONL | | | |
|
||||
| LLM | LLM | LLM(大语言模型) | | |
|
||||
| MCP | MCP | | | |
|
||||
| PR | PR | PR(Pull Request) | | |
|
||||
| RAG | RAG | RAG(检索增强生成) | | |
|
||||
| SDK | SDK | | | |
|
||||
| SSE | SSE | SSE(Server-Sent Events) | | |
|
||||
|
||||
## 英文类(中英文文本中均使用英文)
|
||||
|
||||
| English | 中文 | 首次出现 | 不要译作 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| agent | agent | agent(智能体) | | |
|
||||
| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |
|
||||
| agent loop | agent loop | agent loop(智能体循环) | | |
|
||||
| backlog | backlog | backlog(待翻清单) | | 仅在双语翻译语境里括注`待翻清单` |
|
||||
| blob hash | blob hash | | | `git hash-object` 的结果 |
|
||||
| Cordis | Cordis | | | |
|
||||
| dispose | dispose | dispose(资源释放) | | |
|
||||
| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |
|
||||
| fiber | fiber | | | |
|
||||
| fixture | fixture | fixture(测试前置数据) | | |
|
||||
| fork | fork | | | |
|
||||
| Function Calling | Function Calling | Function Calling(函数调用) | | |
|
||||
| harness | harness | | | |
|
||||
| harness engineering | harness engineering | | | |
|
||||
| lint | lint | | | |
|
||||
| mock | mock | | | 保留英文;指测试替身 |
|
||||
| loader | loader | | | |
|
||||
| manifest | manifest | manifest(元数据清单) | | |
|
||||
| monorepo | monorepo | | | |
|
||||
| schema | schema | | | |
|
||||
| schema DSL | schema DSL | | | |
|
||||
| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |
|
||||
| skill | skill | skill(技能) | | |
|
||||
| spawn | spawn | | | |
|
||||
| steering | steering | steering(中途引导) | | |
|
||||
| task id | task id | | 任务 id | 保留英文 |
|
||||
| subagent | subagent | | | |
|
||||
| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |
|
||||
| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |
|
||||
| waterfall | waterfall | waterfall(瀑布式事件) | | |
|
||||
| wheel | wheel 包 | | | Python 打包格式 |
|
||||
| worktree | worktree | | | git 工作区概念 |
|
||||
|
||||
## 双语类(中英文文本各自使用中英文)
|
||||
|
||||
| English | 中文 | 首次出现 | 不要译作 | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| adapter | 适配器 | | | |
|
||||
| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |
|
||||
| append-only | 仅追加 | | | |
|
||||
| artifact | 产物 | | | |
|
||||
| backend | 后端 | | | |
|
||||
| background task | 后台任务 | | | |
|
||||
| block | 块 | | | |
|
||||
| build target | 构建目标 | | | |
|
||||
| cancel | 取消 | | | |
|
||||
| capability | 能力 | | | |
|
||||
| checkpoint | 检查点 | | | |
|
||||
| chunk | 分片 | | | |
|
||||
| compaction | 压缩 | 压缩(compaction) | | |
|
||||
| companion tool | 配套工具 | | | |
|
||||
| config | 配置 | | | |
|
||||
| consumer | 消费方 | | | |
|
||||
| content block | 内容块 | | | |
|
||||
| Cookbook | 实操手册 | | | 文档标题用语 |
|
||||
| context | 上下文 | | | |
|
||||
| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指"另一侧"时可写「另一侧」 |
|
||||
| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |
|
||||
| contract | 契约 | | | 如:`pairing contract` →`配对契约` |
|
||||
| coverage | 覆盖率 | | | |
|
||||
| crash recovery | 崩溃恢复 | | | |
|
||||
| deploy root | 部署根目录 | | | |
|
||||
| durability | 持久性 | | | |
|
||||
| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |
|
||||
| event | 事件 | | | |
|
||||
| event log | 事件日志 | | | |
|
||||
| event stream | 事件流 | | | |
|
||||
| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |
|
||||
| executor | 执行器 | | | |
|
||||
| extension | 扩展 | | | |
|
||||
| extension point | 扩展点 | | | 注意与 `seam` 区分 |
|
||||
| fail-fast | 快速失败 | | | |
|
||||
| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |
|
||||
| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |
|
||||
| finish reason | 结束原因 | | | |
|
||||
| foreground run | 前台运行 | | | |
|
||||
| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |
|
||||
| hook | 钩子 | | | |
|
||||
| implementation | 实现 | | | |
|
||||
| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |
|
||||
| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |
|
||||
| injection | 注入 | | | |
|
||||
| integration | 集成 | | | |
|
||||
| interface | 接口 | | | |
|
||||
| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |
|
||||
| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |
|
||||
| merge | 合并 | | | |
|
||||
| message | 消息 | | | |
|
||||
| mod | 模组 | | | |
|
||||
| model provider | 模型提供方 | | | |
|
||||
| module | 模块 | | | |
|
||||
| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |
|
||||
| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |
|
||||
| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |
|
||||
| pairing | 配对 | | | |
|
||||
| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |
|
||||
| permission | 权限 | | | |
|
||||
| persistence | 持久化 | | | |
|
||||
| pipeline | 流水线 | | | |
|
||||
| plugin | 插件 | | | |
|
||||
| prompt | 提示词 | | | |
|
||||
| provider | 提供方 | | | |
|
||||
| provider-neutral | 提供方无关 | | | |
|
||||
| quality gate | 质量门禁 | | | |
|
||||
| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |
|
||||
| reasoning_content | 思考内容 | | | |
|
||||
| registry | 注册表 | | | |
|
||||
| replay | 回放 | | | |
|
||||
| resume | 恢复 | | | |
|
||||
| runtime | 运行时 | | | |
|
||||
| sandbox | 沙箱 | | | |
|
||||
| service | 服务 | | | |
|
||||
| serving surface | 对外服务接口 | | | |
|
||||
| session | 会话 | | | |
|
||||
| session event | 会话事件 | | | |
|
||||
| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |
|
||||
| smoke test | 冒烟测试 | | | |
|
||||
| snapshot | 快照 | | | |
|
||||
| source of truth | 真源 | | | |
|
||||
| spine | 主干 | | | |
|
||||
| staged | 暂存 | | | 沿用 git 官方中文翻译 |
|
||||
| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |
|
||||
| step | 步骤 | | | |
|
||||
| stream | 流 | | | |
|
||||
| streaming | 流式输出 | | | |
|
||||
| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |
|
||||
| system prompt | 系统提示词 | | | |
|
||||
| taxonomy | 分类体系 | | | |
|
||||
| token usage | token 用量 | | | |
|
||||
| tool | 工具 | | | |
|
||||
| tool call | 工具调用 | | | |
|
||||
| tool result | 工具结果 | | | |
|
||||
| tool schema | 工具 schema | | | |
|
||||
| toolkit | 工具包 | | | |
|
||||
| turn | 轮次 | | | |
|
||||
| VFS | VFS | 虚拟文件系统(VFS) | | |
|
||||
| typecheck | 类型检查 | | | |
|
||||
| vocabulary | 词汇 | | | |
|
||||
| wire format | 协议格式 | 协议格式(wire format) | | |
|
||||
| workflow | 工作流 | | | |
|
||||
| wrapper | 包装层 | | | 软件层或 SDK 包装层 |
|
||||
| wrapper script | 包装脚本 | | | 可执行脚本包装层 |
|
||||
|
||||
143
docs/i18n/translation-prompt.md
Normal file
143
docs/i18n/translation-prompt.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Translation prompt (pipeline asset)
|
||||
|
||||
本文件是自动翻译流水线使用的 prompt 模板;从 `# Translation Prompt` 开始的正文会逐字进入模型请求,因此本文件不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时会把 [translation-rules.md](translation-rules.md) 全文填入 `{{translation_rules}}`,把 [terminology.md](terminology.md) 整表填入 `{{terminology}}`,以免模板另存一份规则而日后失去同步。[style-samples.md](style-samples.md) 定义文体,模板中的 Examples 只用于说明典型问题;术语表、忠实性和结构规则优先于样例,样例只在这些硬性约束内决定文体。修改本文件会改变翻译行为,需正常经过 PR 评审。
|
||||
|
||||
## 占位符契约
|
||||
|
||||
流水线渲染模板时替换以下占位符,除此之外不改写系统消息:
|
||||
|
||||
| 占位符 | 填入内容 | 来源 |
|
||||
|---|---|---|
|
||||
| `{{source_lang}}` | 源语言名(`English` / `Chinese`) | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` |
|
||||
| `{{target_lang}}` | 目标语言名(`Chinese` / `English`) | 与 `{{source_lang}}` 相对 |
|
||||
| `{{translation_rules}}` | [translation-rules.md](translation-rules.md) 全文(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
|
||||
| `{{terminology}}` | [terminology.md](terminology.md) 的完整表格(Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
|
||||
| `{{source_filename}}` | 源文档的 basename(如 `foo.md` 或 `foo.zh.md`) | 由流水线从待译文件路径取得 |
|
||||
| `{{source_filename_zh}}` | 中文侧 basename(如 `foo.zh.md`) | 英文源追加 `.zh`;中文源使用自身 basename |
|
||||
|
||||
例如,英译中时若源文件是 `foo.md`,`{{source_filename}}` 填 `foo.md`,`{{source_filename_zh}}` 填 `foo.zh.md`;中译英时若源文件是 `foo.zh.md`,两个占位符都填 `foo.zh.md`。
|
||||
|
||||
流水线只识别上表中的占位符,并且一次翻译整篇文档。它不支持 `{{to}}`、`{{title_prompt}}`、`{{summary_prompt}}`、`{{terms_prompt}}`、`{{imt_style_guide}}` 或 `%%` 分段协议。输出必须是一个以 `<dsh-translation-response>` 为根元素的 XML 文档;三个子元素中的 Markdown 内容都放在 CDATA 中。内容出现 `]]>` 时写成 `]]]]><![CDATA[>`,XML 解析后仍会还原为原文。
|
||||
|
||||
## Few-shot 金标
|
||||
|
||||
流水线使用**整篇文档**的中英对照作为 few-shot,不是模板内嵌的句子级正误例。以下 5 组配对文档均经过人工评审,并以仓库当前版本为准,随仓库一同更新:
|
||||
|
||||
- `README.md` ↔ `README.zh.md`
|
||||
- `docs/development.md` ↔ `docs/development.zh.md`
|
||||
- `docs/i18n/README.md` ↔ `docs/i18n/README.zh.md`
|
||||
- `docs/i18n/translation-rules.md` ↔ `docs/i18n/translation-rules.zh.md`
|
||||
- `docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md` ↔ 对应 `.zh.md`
|
||||
|
||||
注入时按当前翻译方向选择每组的源侧与目标侧:user 消息包含源文档全文,assistant 消息采用模板正文规定的 XML 协议;`translation` 与 `final` 都放入目标文档全文,`review` 填 `- [None] No corrections.`。CDATA 遵循上文的 `]]>` 拆分规则。上下文不足时,按上列顺序从后往前删减示例组数。这 5 组也是评审校准锚点;改动任何一组都会改变流水线行为。
|
||||
|
||||
## 模板正文
|
||||
|
||||
````text
|
||||
# Translation Prompt
|
||||
|
||||
You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose.
|
||||
|
||||
## Binding Translation Rules
|
||||
|
||||
The canonical repository rules below are injected verbatim. Apply every direction-appropriate requirement. In those rules, the authored document is the source for this request and the generated document is its counterpart.
|
||||
|
||||
{{translation_rules}}
|
||||
|
||||
## Request-Specific Structure
|
||||
|
||||
- The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1.
|
||||
- Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged.
|
||||
|
||||
## Binding Terminology
|
||||
|
||||
Apply the current table below exactly as required by the injected translation rules.
|
||||
|
||||
{{terminology}}
|
||||
|
||||
## Output Format
|
||||
|
||||
Return exactly one well-formed XML document with this root and these three child elements. Do not wrap it in a Markdown code fence. Put all Markdown and review text inside CDATA. If any content contains the CDATA terminator, split it as `]]]]><![CDATA[>` so XML parsing reconstructs the original `]]>` sequence.
|
||||
|
||||
```xml
|
||||
<dsh-translation-response version="1">
|
||||
<translation><![CDATA[
|
||||
(Complete first-pass translation)
|
||||
]]></translation>
|
||||
<review><![CDATA[
|
||||
- [Tone] Replaced a literal rendering with the established target-language phrasing.
|
||||
- [Terminology] Applied the binding sidecar record term.
|
||||
]]></review>
|
||||
<final><![CDATA[
|
||||
(Complete corrected translation)
|
||||
]]></final>
|
||||
</dsh-translation-response>
|
||||
```
|
||||
|
||||
## Self-Review Instructions
|
||||
|
||||
After writing `<translation>`, re-read it in the target language without looking at the source. Then apply the injected translation rules as a clause-by-clause comparison against the source and record actual corrections in English inside `<review>`. Apply every recorded correction in `<final>`. If no correction is needed, write only `- [None] No corrections.` in `<review>` and copy `<translation>` unchanged into `<final>`.
|
||||
|
||||
## Examples
|
||||
|
||||
Follow the Good versions; these sentence-level examples illustrate error categories, not the assistant-message wire format.
|
||||
|
||||
### Colloquial verb → Professional verb
|
||||
- Source: `The repo pins pnpm@11.7.0 in package.json`
|
||||
- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`
|
||||
- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`
|
||||
|
||||
### Run-on sentence → Natural phrasing with pause
|
||||
- Source: `Read docs/architecture.md before changing anything under packages/.`
|
||||
- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`
|
||||
- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`
|
||||
|
||||
### Stiff passive voice → Active and natural
|
||||
- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`
|
||||
- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`
|
||||
- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`
|
||||
|
||||
### Invented word → Natural expression
|
||||
- Source: `A sidecar record of both blob hashes makes consistency checkable`
|
||||
- Bad: `旁挂记录两侧 blob hash,使一致性可检查`
|
||||
- Good: `伴随记录保存两侧 blob hash,使一致性可检查`
|
||||
|
||||
### Overly literal → Meaningful rendering
|
||||
- Source: `awkward phrasing is easier to hear without the source anchoring you`
|
||||
- Bad: `没有源文锚着,别扭的表述更容易被听出来`
|
||||
- Good: `不对照原文时,更容易察觉别扭的表达`
|
||||
|
||||
### Terminology — keep the binding English form
|
||||
- Source: `typed service seams, and explicit extension points`
|
||||
- Bad: `类型化的服务 seam(扩展点)与显式扩展点`
|
||||
- Good: `类型化的服务 seam 与显式扩展点`
|
||||
|
||||
### Slang → Professional phrasing
|
||||
- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`
|
||||
- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`
|
||||
- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`
|
||||
|
||||
### Chinese → English — idiomatic subject and predicate
|
||||
- Source: `门禁绿并不代表译文内容正确。`
|
||||
- Bad: `The gate green does not represent that the translation content is correct.`
|
||||
- Good: `A green gate does not mean the translation is correct.`
|
||||
|
||||
### Code block comments — never translate
|
||||
- Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)`
|
||||
- Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)`
|
||||
- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical)
|
||||
|
||||
### Language switcher — English to Chinese
|
||||
- Source: `English | [中文](README.zh.md)`
|
||||
- Bad: `English | [中文](README.zh.md)`
|
||||
- Good: `[English](README.md) | 中文`
|
||||
|
||||
### Language switcher — Chinese to English
|
||||
- Source: `[English](README.md) | 中文`
|
||||
- Bad: `[English](README.md) | 中文`
|
||||
- Good: `English | [中文](README.zh.md)`
|
||||
|
||||
---
|
||||
|
||||
Now translate the following document:
|
||||
````
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
translation-rules.md: 4e190f58469f7d402dfa5600f17cf1621484f138
|
||||
translation-rules.zh.md: 89a1cddd23126f24354ce1f8d9af4e7bd403454d
|
||||
translation-rules.md: fb6aa9ac05bebe68ff9213af99f64457bdb1ad6f
|
||||
translation-rules.zh.md: 04dd0a704e19502c676ea0966437870c5af0624f
|
||||
|
||||
@@ -6,18 +6,27 @@ How to translate between the two sides of a documentation pair in this repo. Bot
|
||||
|
||||
## Faithfulness
|
||||
|
||||
- The counterpart MUST say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.
|
||||
- The counterpart SHOULD read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.
|
||||
- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.
|
||||
- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.
|
||||
- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.
|
||||
|
||||
## Voice
|
||||
|
||||
- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.
|
||||
- Write as a native technical author restating the content, not as a translator transposing sentences. Then verify against the source clause by clause: nothing added, nothing dropped — fluency never justifies losing a clause.
|
||||
- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).
|
||||
- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.
|
||||
- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.
|
||||
- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.
|
||||
|
||||
## Structure preservation
|
||||
|
||||
The paired files MUST match one to one in:
|
||||
The pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:
|
||||
|
||||
- heading hierarchy (same levels, same order — heading TEXT is translated),
|
||||
- list shape and numbering,
|
||||
- tables (same columns, same row order; header cells translated per terminology),
|
||||
- fenced code blocks — **byte-identical, including comments**; code is part of the verified surface (` ```ts ` blocks compile under `doc-typecheck`), and an edited comment is drift the fence-count gate cannot see,
|
||||
- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,
|
||||
- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,
|
||||
- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.
|
||||
|
||||
@@ -25,9 +34,9 @@ The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical
|
||||
|
||||
## Terminology
|
||||
|
||||
- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. When the Chinese side is authored first, the English counterpart uses the table's English column the same way.
|
||||
- A technical term NOT in the table MAY be translated only when a major Chinese-language OSS or vendor doc has an established rendering for it (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs). Cite the precedent in the PR.
|
||||
- A term with NO established precedent MUST stay in English in the translation and MUST be listed in the PR description under 「待定术语」(pending terms) with a suggested rendering for the reviewer to decide. MUST NOT invent a Chinese rendering inline — an unprecedented translation creates exactly the ambiguity the terminology table exists to prevent. Decided terms then land in [terminology.md](terminology.md) in the same PR or a follow-up.
|
||||
- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its "不要译作" prohibitions. A Chinese target uses the "中文" column and its "首次出现" annotation; an English target uses the "English" column without adding a Chinese gloss.
|
||||
- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.
|
||||
- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.
|
||||
|
||||
## Typography
|
||||
|
||||
@@ -35,6 +44,7 @@ These rules govern the Chinese side; the English side follows the repo's normal
|
||||
|
||||
- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.
|
||||
- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).
|
||||
- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.
|
||||
- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.
|
||||
- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.
|
||||
- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.
|
||||
@@ -45,7 +55,7 @@ These rules govern the Chinese side; the English side follows the repo's normal
|
||||
|
||||
- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.
|
||||
- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you.
|
||||
- The mechanical contract (consistency record, switcher, structure, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers.
|
||||
- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -2,59 +2,69 @@
|
||||
|
||||
[English](translation-rules.md) | 中文
|
||||
|
||||
本文规定如何在本仓库文档配对的两侧之间进行翻译。两种语言同权(见 [README.md](README.md)):一次变更用任一语言撰写,那一侧就是这次更新的源——本文的规则约束的是产出或更新另一侧。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**自行裁量。
|
||||
本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效;应用这些规则的仓库内置 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。
|
||||
|
||||
## 忠实性
|
||||
|
||||
- 另一侧必须说撰写侧所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜:改正错的那一侧,并在同一个变更里把另一侧带上。
|
||||
- 另一侧应当读起来是其语言自然的技术文字,而不是逐词对照。翻译语义,在目标语言语法需要处重组句子,并保持原作者的语域——简练的保持简练。
|
||||
- 不要翻译不可译的东西:一句话如果依赖源语言的习语而无法自然转换,就翻译它的意思,而不是习语本身。
|
||||
- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。
|
||||
- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。
|
||||
- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。
|
||||
|
||||
## 行文
|
||||
|
||||
- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。
|
||||
- 以母语技术作者的身份重述内容,而不是逐句转写的译者。写完后逐句对照原文核验:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。
|
||||
- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。
|
||||
- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。
|
||||
- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。
|
||||
- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。
|
||||
|
||||
## 结构保持
|
||||
|
||||
配对的两个文件必须在以下方面一一对应:
|
||||
配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:
|
||||
|
||||
- 标题层级(相同级别、相同顺序——标题的**文字**要翻译),
|
||||
- 列表形态与编号,
|
||||
- 表格(相同的列、相同的行序;表头单元格按术语表翻译),
|
||||
- 围栏代码块——**逐字节一致,包括注释**;代码属于受验证的范围(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移,
|
||||
- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排,
|
||||
- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标——按约定是 `.md` 路径而非 `.zh.md` 兄弟文件——这样某对文档先于相邻文件落地时,链接也永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。
|
||||
- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);
|
||||
- 列表形态与编号;
|
||||
- 表格(相同的列、相同的行序;表头单元格按术语表翻译);
|
||||
- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;
|
||||
- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;
|
||||
- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。
|
||||
|
||||
本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。
|
||||
|
||||
## 术语
|
||||
|
||||
- [terminology.md](terminology.md) 是双向的术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。中文先行撰写时,英文另一侧同样按表中英文列使用术语。
|
||||
- 表中**没有**的技术术语,只有当某个主要中文 OSS 或厂商文档已有成型译法时(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档)才可以翻译。在 PR 中注明先例出处。
|
||||
- **没有**成型先例的术语,译文中必须保留英文,并且必须在 PR 描述的「待定术语」下列出、附上建议译法交评审者定夺。禁止就地发明中文译法——无先例的翻译恰恰制造了术语表要防止的歧义。定下来的术语随后在同一个 PR 或后续 PR 进入 [terminology.md](terminology.md)。
|
||||
- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。
|
||||
- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。
|
||||
- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。
|
||||
|
||||
## 排版
|
||||
|
||||
本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:
|
||||
本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:
|
||||
|
||||
- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。
|
||||
- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。
|
||||
- 并列顿开:中文的并列项之间用顿号(、),不用逗号。
|
||||
- 禁止使用全角数字或全角拉丁字母——永远不写 `123`,永远写 `123`。
|
||||
- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek——除非引用代码,否则绝不写 `github`/`Github`。
|
||||
- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。
|
||||
- 顿号:中文的并列项之间使用顿号(、),而非逗号。
|
||||
- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。
|
||||
- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。
|
||||
- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。
|
||||
- 强调标记(`**加粗**`、`*斜体*`)落在与另一侧相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。
|
||||
- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。
|
||||
|
||||
## 质量线
|
||||
## 质量标准
|
||||
|
||||
- 一对文档的完成标准:一位双语工程师只读其中任一文件,得到与另一文件读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。
|
||||
- 交付前,对照本文自查一遍,并**只读另一侧**再通读一遍、不看源侧对照;没有源文锚着,别扭的表述更容易被听出来。
|
||||
- 机械契约(一致性记录、切换行、结构、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。
|
||||
- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。
|
||||
- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。
|
||||
- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需人工核对。
|
||||
|
||||
## 参考资料
|
||||
|
||||
本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:
|
||||
|
||||
- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)——中西文混排空格与标点的社区事实标准。
|
||||
- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)——与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。
|
||||
- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)——最大的中文本地化团队的术语首现与标点实践。
|
||||
- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)——逐术语的译/留决策与语气。
|
||||
- [zh-style-guide](https://zh-style-guide.readthedocs.io)——社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。
|
||||
- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides)——排版学与厂商本地化的正式基线。
|
||||
- GB/T 19682-2005《翻译服务译文质量要求》——国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。
|
||||
- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。
|
||||
- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。
|
||||
- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。
|
||||
- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。
|
||||
- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。
|
||||
- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。
|
||||
- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。
|
||||
|
||||
@@ -18,7 +18,6 @@ flowchart TD
|
||||
end
|
||||
subgraph group_core["packages/core"]
|
||||
pkg_agent["agent"]
|
||||
pkg_agent_core["agent-core"]
|
||||
pkg_agent_loop["agent-loop"]
|
||||
pkg_scope["scope"]
|
||||
pkg_session["session"]
|
||||
@@ -85,19 +84,22 @@ flowchart TD
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
end
|
||||
subgraph group_session_query["packages/session-query"]
|
||||
pkg_session_query["session-query"]
|
||||
end
|
||||
subgraph group_support["packages/support"]
|
||||
pkg_acp_snapshot["acp-snapshot"]
|
||||
pkg_invariants["invariants"]
|
||||
pkg_llm_replay["llm-replay"]
|
||||
pkg_loader_smoke["loader-smoke"]
|
||||
pkg_subagent_mock["subagent-mock"]
|
||||
end
|
||||
subgraph group_ui["packages/ui"]
|
||||
pkg_acp["acp"]
|
||||
pkg_acp_agent["acp-agent"]
|
||||
pkg_app_boot["app-boot"]
|
||||
pkg_jsonrpc["jsonrpc"]
|
||||
pkg_jsonrpc_agent["jsonrpc-agent"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
pkg_permission["permission"]
|
||||
pkg_stdio["stdio"]
|
||||
pkg_tool_ask_user["tool-ask-user"]
|
||||
pkg_user_approval["user-approval"]
|
||||
pkg_user_interaction["user-interaction"]
|
||||
@@ -106,13 +108,29 @@ flowchart TD
|
||||
pkg_code_runtime["code-runtime"]
|
||||
pkg_code_runtime_worker["code-runtime-worker"]
|
||||
end
|
||||
subgraph group_context["packages/context"]
|
||||
pkg_time_context["time-context"]
|
||||
end
|
||||
subgraph group_examples["packages/examples"]
|
||||
pkg_acp_demo["acp-demo"]
|
||||
pkg_agent_spine_demo["agent-spine-demo"]
|
||||
pkg_jsonrpc_demo["jsonrpc-demo"]
|
||||
pkg_stdio_demo["stdio-demo"]
|
||||
end
|
||||
subgraph group_guard["packages/guard"]
|
||||
pkg_repeat_tool_guard["repeat-tool-guard"]
|
||||
end
|
||||
subgraph group_mcp["packages/mcp"]
|
||||
pkg_mcp_client["mcp-client"]
|
||||
end
|
||||
subgraph group_sandbox["packages/sandbox"]
|
||||
pkg_sandbox["sandbox"]
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
end
|
||||
subgraph group_tasks["packages/tasks"]
|
||||
pkg_tasks["tasks"]
|
||||
pkg_tool_tasks["tool-tasks"]
|
||||
end
|
||||
subgraph group_workflow["packages/workflow"]
|
||||
pkg_tool_workflow["tool-workflow"]
|
||||
pkg_workflow["workflow"]
|
||||
@@ -136,7 +154,6 @@ flowchart TD
|
||||
pkg_agent --> pkg_scope
|
||||
pkg_agent --> pkg_session
|
||||
pkg_agent --> pkg_system_prompt
|
||||
pkg_bash --> pkg_brand
|
||||
pkg_bash --> pkg_sandbox
|
||||
pkg_bash --> pkg_session
|
||||
pkg_fs_local --> pkg_fs
|
||||
@@ -167,6 +184,9 @@ flowchart TD
|
||||
pkg_session_persistence_jsonl --> pkg_session_persistence
|
||||
pkg_session_persistence_sqlite --> pkg_session
|
||||
pkg_session_persistence_sqlite --> pkg_session_persistence
|
||||
pkg_session_query --> pkg_llm
|
||||
pkg_session_query --> pkg_session
|
||||
pkg_session_query --> pkg_session_persistence
|
||||
pkg_invariants --> pkg_agent
|
||||
pkg_invariants --> pkg_llm
|
||||
pkg_invariants --> pkg_scope
|
||||
@@ -179,6 +199,12 @@ flowchart TD
|
||||
pkg_user_approval --> pkg_system_prompt
|
||||
pkg_user_interaction --> pkg_agent
|
||||
pkg_user_interaction --> pkg_llm
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_system_prompt
|
||||
pkg_tasks --> pkg_agent
|
||||
pkg_tasks --> pkg_brand
|
||||
pkg_tasks --> pkg_session
|
||||
pkg_tasks --> pkg_timeout
|
||||
pkg_workflow --> pkg_agent
|
||||
pkg_workflow --> pkg_brand
|
||||
pkg_workflow --> pkg_llm
|
||||
@@ -192,6 +218,10 @@ flowchart TD
|
||||
pkg_bash_sandbox --> pkg_bash
|
||||
pkg_bash_sandbox --> pkg_bash_local
|
||||
pkg_bash_sandbox --> pkg_sandbox
|
||||
pkg_permission --> pkg_bash
|
||||
pkg_permission --> pkg_sandbox
|
||||
pkg_permission --> pkg_session
|
||||
pkg_permission --> pkg_user_approval
|
||||
pkg_agent_loop --> pkg_agent
|
||||
pkg_agent_loop --> pkg_llm
|
||||
pkg_agent_loop --> pkg_scope
|
||||
@@ -204,6 +234,7 @@ flowchart TD
|
||||
pkg_tool_bash --> pkg_llm
|
||||
pkg_tool_bash --> pkg_sandbox
|
||||
pkg_tool_bash --> pkg_system_prompt
|
||||
pkg_tool_bash --> pkg_tasks
|
||||
pkg_tool_bash --> pkg_tools
|
||||
pkg_tool_bash --> pkg_user_approval
|
||||
pkg_tool_fs --> pkg_fs
|
||||
@@ -247,22 +278,17 @@ flowchart TD
|
||||
pkg_tool_ask_user --> pkg_user_interaction
|
||||
pkg_repeat_tool_guard --> pkg_agent
|
||||
pkg_repeat_tool_guard --> pkg_tools
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_tools
|
||||
pkg_tool_tasks --> pkg_agent
|
||||
pkg_tool_tasks --> pkg_system_prompt
|
||||
pkg_tool_tasks --> pkg_tasks
|
||||
pkg_tool_tasks --> pkg_tools
|
||||
pkg_tool_workflow --> pkg_agent
|
||||
pkg_tool_workflow --> pkg_llm
|
||||
pkg_tool_workflow --> pkg_system_prompt
|
||||
pkg_tool_workflow --> pkg_tools
|
||||
pkg_tool_workflow --> pkg_workflow
|
||||
pkg_agent_core --> pkg_agent
|
||||
pkg_agent_core --> pkg_agent_loop
|
||||
pkg_agent_core --> pkg_invariants
|
||||
pkg_agent_core --> pkg_llm
|
||||
pkg_agent_core --> pkg_session
|
||||
pkg_agent_core --> pkg_skill
|
||||
pkg_agent_core --> pkg_skill_local
|
||||
pkg_agent_core --> pkg_system_prompt
|
||||
pkg_agent_core --> pkg_tool_bash
|
||||
pkg_agent_core --> pkg_tool_skill
|
||||
pkg_agent_core --> pkg_tools
|
||||
pkg_subagent_acp --> pkg_agent
|
||||
pkg_subagent_acp --> pkg_llm
|
||||
pkg_subagent_acp --> pkg_subagent
|
||||
@@ -276,6 +302,7 @@ flowchart TD
|
||||
pkg_tool_subagent --> pkg_agent
|
||||
pkg_tool_subagent --> pkg_llm
|
||||
pkg_tool_subagent --> pkg_subagent
|
||||
pkg_tool_subagent --> pkg_tasks
|
||||
pkg_tool_subagent --> pkg_tools
|
||||
pkg_hooks_claude --> pkg_agent
|
||||
pkg_hooks_claude --> pkg_hook_protocol
|
||||
@@ -290,6 +317,7 @@ flowchart TD
|
||||
pkg_acp --> pkg_bash
|
||||
pkg_acp --> pkg_llm
|
||||
pkg_acp --> pkg_mode
|
||||
pkg_acp --> pkg_permission
|
||||
pkg_acp --> pkg_sandbox
|
||||
pkg_acp --> pkg_session
|
||||
pkg_acp --> pkg_session_persistence
|
||||
@@ -301,6 +329,24 @@ flowchart TD
|
||||
pkg_jsonrpc --> pkg_llm_deepseek
|
||||
pkg_jsonrpc --> pkg_session
|
||||
pkg_jsonrpc --> pkg_subagent
|
||||
pkg_stdio --> pkg_agent
|
||||
pkg_stdio --> pkg_llm
|
||||
pkg_stdio --> pkg_mode
|
||||
pkg_stdio --> pkg_session
|
||||
pkg_stdio --> pkg_user_interaction
|
||||
pkg_agent_spine_demo --> pkg_agent
|
||||
pkg_agent_spine_demo --> pkg_agent_loop
|
||||
pkg_agent_spine_demo --> pkg_invariants
|
||||
pkg_agent_spine_demo --> pkg_llm
|
||||
pkg_agent_spine_demo --> pkg_session
|
||||
pkg_agent_spine_demo --> pkg_skill
|
||||
pkg_agent_spine_demo --> pkg_skill_local
|
||||
pkg_agent_spine_demo --> pkg_system_prompt
|
||||
pkg_agent_spine_demo --> pkg_tasks
|
||||
pkg_agent_spine_demo --> pkg_tool_bash
|
||||
pkg_agent_spine_demo --> pkg_tool_skill
|
||||
pkg_agent_spine_demo --> pkg_tool_tasks
|
||||
pkg_agent_spine_demo --> pkg_tools
|
||||
pkg_workflow_workerthread --> pkg_agent
|
||||
pkg_workflow_workerthread --> pkg_brand
|
||||
pkg_workflow_workerthread --> pkg_llm
|
||||
@@ -314,22 +360,22 @@ flowchart TD
|
||||
pkg_subagent_fork --> pkg_subagent_inprocess
|
||||
pkg_subagent_spawn --> pkg_subagent
|
||||
pkg_subagent_spawn --> pkg_subagent_inprocess
|
||||
pkg_acp_agent --> pkg_acp
|
||||
pkg_acp_agent --> pkg_agent_core
|
||||
pkg_acp_agent --> pkg_app_boot
|
||||
pkg_acp_agent --> pkg_session_persistence_jsonl
|
||||
pkg_acp_agent --> pkg_tools
|
||||
pkg_acp_agent --> pkg_user_interaction
|
||||
pkg_stdio_agent --> pkg_agent
|
||||
pkg_stdio_agent --> pkg_agent_core
|
||||
pkg_stdio_agent --> pkg_app_boot
|
||||
pkg_stdio_agent --> pkg_llm
|
||||
pkg_stdio_agent --> pkg_mode
|
||||
pkg_stdio_agent --> pkg_session
|
||||
pkg_stdio_agent --> pkg_session_persistence_jsonl
|
||||
pkg_stdio_agent --> pkg_tool_ask_user
|
||||
pkg_stdio_agent --> pkg_tools
|
||||
pkg_stdio_agent --> pkg_user_interaction
|
||||
pkg_acp_demo --> pkg_acp
|
||||
pkg_acp_demo --> pkg_agent_spine_demo
|
||||
pkg_acp_demo --> pkg_app_boot
|
||||
pkg_acp_demo --> pkg_session_persistence_jsonl
|
||||
pkg_acp_demo --> pkg_tools
|
||||
pkg_acp_demo --> pkg_user_interaction
|
||||
pkg_stdio_demo --> pkg_agent
|
||||
pkg_stdio_demo --> pkg_agent_spine_demo
|
||||
pkg_stdio_demo --> pkg_app_boot
|
||||
pkg_stdio_demo --> pkg_llm
|
||||
pkg_stdio_demo --> pkg_session
|
||||
pkg_stdio_demo --> pkg_session_persistence_jsonl
|
||||
pkg_stdio_demo --> pkg_stdio
|
||||
pkg_stdio_demo --> pkg_tool_ask_user
|
||||
pkg_stdio_demo --> pkg_tools
|
||||
pkg_stdio_demo --> pkg_user_interaction
|
||||
```
|
||||
|
||||
| Package | Group | Depends on |
|
||||
@@ -340,9 +386,10 @@ flowchart TD
|
||||
| [`skill`](../packages/skill/skill) | `skill` | — |
|
||||
| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — |
|
||||
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
|
||||
| [`loader-smoke`](../packages/support/loader-smoke) | `support` | — |
|
||||
| [`app-boot`](../packages/ui/app-boot) | `ui` | — |
|
||||
| [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — |
|
||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
|
||||
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) |
|
||||
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
|
||||
@@ -353,7 +400,7 @@ flowchart TD
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`brand`](../packages/util/brand), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) |
|
||||
@@ -370,14 +417,18 @@ flowchart TD
|
||||
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
|
||||
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
|
||||
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) |
|
||||
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
|
||||
@@ -389,17 +440,20 @@ flowchart TD
|
||||
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
|
||||
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
|
||||
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) |
|
||||
| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) |
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`mode`](../packages/mode/mode), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
|
||||
@@ -23,7 +23,7 @@ An approval question was put to the answerer chain — log-only audit (like `hoo
|
||||
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:84`](../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
#### `approval/decided` — log-only
|
||||
|
||||
@@ -33,7 +33,7 @@ The outcome of a prior `approval/asked` (same `id`) — log-only audit. Exactly
|
||||
'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
|
||||
```
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:95`](../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
#### `approval/policy` — log-only
|
||||
|
||||
@@ -43,7 +43,7 @@ The session's approval policy was switched — log-only, durable, replayable, ne
|
||||
'approval/policy': { policy: ApprovalPolicy }
|
||||
```
|
||||
|
||||
Source: [`packages/ui/user-approval/src/index.ts:107`](../packages/ui/user-approval/src/index.ts)
|
||||
Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts)
|
||||
|
||||
### `assistant/*`
|
||||
|
||||
@@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity.
|
||||
|
||||
Types: [StreamChunk](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:322`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -69,19 +69,19 @@ Assembled assistant message for one step (derived history uses this). Carries th
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:329`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `bash/*`
|
||||
|
||||
#### `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:34`](../packages/bash/bash/src/session-mode.ts)
|
||||
Source: [`packages/bash/bash/src/session-mode.ts:22`](../packages/bash/bash/src/session-mode.ts)
|
||||
|
||||
### `compact/*`
|
||||
|
||||
@@ -93,7 +93,7 @@ Marks the end of a compaction — log-only, releases the lock. `error` set if su
|
||||
'compact/end': { turn: number; error?: string }
|
||||
```
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:46`](../packages/compact/compact/src/types.ts)
|
||||
Source: [`packages/compact/compact/src/types.ts:38`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
#### `compact/start` — log-only
|
||||
|
||||
@@ -103,7 +103,7 @@ Marks the start of a compaction — log-only, holds the lock until `compact/end`
|
||||
'compact/start': { turn: number }
|
||||
```
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:23`](../packages/compact/compact/src/types.ts)
|
||||
Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
#### `compact/summary` — log-only
|
||||
|
||||
@@ -115,7 +115,7 @@ Provenance record of a completed summarization — log-only, no surfaceOp. The s
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/types.ts:30`](../packages/compact/compact/src/types.ts)
|
||||
Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
### `context/*`
|
||||
|
||||
@@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `hook/*`
|
||||
|
||||
@@ -141,17 +141,17 @@ A hook command was invoked at a hook point — log-only provenance (like `compac
|
||||
'hook/invoked': { turn: number; point: string; dialect: HookDialect; matcher?: string; handlerId: string }
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
|
||||
#### `hook/result` — log-only
|
||||
|
||||
A hook command's outcome — log-only, paired with a prior `hook/invoked` (same `handlerId`). `decision` is the dialect-neutral outcome derived by `appendHookResult` (which owns the rule): the hook's parsed decision (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to halt via `continue:false`, else `'pass'`. `exitCode` is the process exit (absent if it never ran), `stderrSummary` the trimmed stderr truncated to the bridge's configured cap (the block reason source on exit 2), `durationMs` the wall-clock runtime (audit timing; snapshot replay normalizes it). `turn` matches the `hook/invoked`.
|
||||
Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime.
|
||||
|
||||
```ts persistence-catalog
|
||||
'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number }
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:45`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
|
||||
### `mode/*`
|
||||
|
||||
@@ -165,11 +165,23 @@ The session mode in force from this point on: log-only, non-surface, whole-value
|
||||
|
||||
Source: [`packages/mode/mode/src/index.ts:57`](../packages/mode/mode/src/index.ts)
|
||||
|
||||
### `permission/*`
|
||||
|
||||
#### `permission/preset` — log-only
|
||||
|
||||
Records the selected preset as durable, log-only user intent. The knob events follow in the same turn and control execution; this event stays out of the model transcript and lets effectivePermissionPreset preserve a selection when bundles match.
|
||||
|
||||
```ts persistence-catalog
|
||||
'permission/preset': { preset: string }
|
||||
```
|
||||
|
||||
Source: [`packages/ui/permission/src/index.ts:33`](../packages/ui/permission/src/index.ts)
|
||||
|
||||
### `prompt/*`
|
||||
|
||||
#### `prompt/blocked` — log-only
|
||||
|
||||
A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. Appended in place of the `user/message` the prompt would have become, so the block survives replay even in a MIXED batch where another queued prompt is allowed (there the turn does not end `rejected`, so the boundary reason alone would not preserve it). `content` is the original prompt the listener rejected; `reason` is the veto text (PromptDecision `block.reason`). NOT a SurfaceEventType: a blocked prompt produces no LLM message and never reaches `deriveMessages()`.
|
||||
Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch.
|
||||
|
||||
```ts persistence-catalog
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
@@ -177,29 +189,29 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
#### `request/header` — log-only
|
||||
|
||||
Full snapshot of the EpochHeader the NEXT request is built under, with the RequestHeaderReason it was recorded whole. Appended by the loop inside the step, before dispatch, on a loop instance's first request-building step (`'initial'`/`'resume'`) or when a delta failed its round-trip guard (`'fallback'`); always records what the request actually used, post-`agent/request`. Anchors the header fold: reconstruction reads the latest snapshot and applies the deltas after it. NOT a SurfaceEventType: it produces no LLM message — it is the request envelope, logged so every request is a pure function of the session log (the reconstructability RFC).
|
||||
Full EpochHeader for the next request, appended inside its step before dispatch. It is log-only and anchors subsequent deltas.
|
||||
|
||||
```ts persistence-catalog
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:374`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `request/header-delta` — log-only
|
||||
|
||||
Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field — the loop never produces one in practice: the prefix is composed once per instance and anchored by that instance's snapshot, so this arm exists for codec totality). Appended by the loop inside the step, before dispatch, when the header for this request differs from the fold of the log so far; the writer verifies `applyHeaderDelta(previous, delta)` reproduces the new header exactly and falls back to a `'fallback'` `request/header` snapshot when it cannot, so a logged delta ALWAYS round-trips. NOT a SurfaceEventType.
|
||||
Log-only amendment to the folded EpochHeader. System and tools use their delta codecs; config and prefix replace whole, with an empty prefix encoding removal. Writers verify round-trip equality or log a fallback snapshot.
|
||||
|
||||
```ts persistence-catalog
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `steering/*`
|
||||
|
||||
@@ -213,7 +225,7 @@ Steering content injected between steps of a running turn.
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:267`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `step/*`
|
||||
|
||||
@@ -225,7 +237,7 @@ Closes step `step` of turn `turn`.
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -235,15 +247,13 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `todo/*`
|
||||
|
||||
#### `todo/write` — log-only
|
||||
|
||||
The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`.
|
||||
|
||||
NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — it is durable, replayable UI state, distinct from the conversation history. It is a `SessionEventMap` member riding the existing `session/event` emit, not a first-class Cordis `interface Events` notification, so it has no cordis-catalog row.
|
||||
Whole-list snapshot; the latest write wins on replay. It is log-only UI state and never enters derived model history.
|
||||
|
||||
```ts persistence-catalog
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
@@ -251,7 +261,7 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess
|
||||
|
||||
Types: [TodoItem](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:272`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -265,11 +275,11 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
|
||||
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/code-dispatch` — log-only
|
||||
|
||||
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized BEFORE dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text. Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter model context; persistence and UIs get every call. Appended inside the parent `run_code`'s execution (the bridge drains its queue before returning), so the turn-enclosure invariant holds by construction.
|
||||
One bridged sub-dispatch from a `run_code` program: the parent `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`), the tool `name` with its JSON-normalized `arguments` — the exact value dispatched, normalized before dispatch, so this append can never fail on payload shape — whether the sub-call errored, and a bounded `resultSummary` of its model-facing text.
|
||||
|
||||
```ts persistence-catalog
|
||||
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; resultSummary: string }
|
||||
@@ -277,7 +287,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i
|
||||
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/tools/src/code-mode.ts:38`](../packages/core/tools/src/code-mode.ts)
|
||||
Source: [`packages/core/tools/src/code-mode.ts:25`](../packages/core/tools/src/code-mode.ts)
|
||||
|
||||
#### `tool/result` — surface
|
||||
|
||||
@@ -289,7 +299,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
|
||||
|
||||
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:345`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:265`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -303,7 +313,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
|
||||
|
||||
Types: [TurnEndReason](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -315,7 +325,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
|
||||
|
||||
Types: [TurnTrigger](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -329,4 +339,4 @@ A user-visible prompt (queued message drained at turn start).
|
||||
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts)
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: resolved (fix in PR #41 `feat/acp-2-bridge`)
|
||||
|
||||
## Executive summary
|
||||
|
||||
One stray line — `export default apply` at the bottom of the ACP plugin — made the ACP server crash the moment any editor connected, because the cordis Loader unwraps a default export and threw away the plugin's `inject` declaration along with it. A second, independent bug (an optional service read that fails through Cordis's traceable-shadow proxy) crashed `session/load` for a different reason. Both shipped green: 178 unit tests at 100% line coverage never caught either, because every test mounted the plugin by hand instead of through the real loader, and the only test that drove the failing requests was skipped in CI. The fixes are one-line each; the durable lesson is that **line coverage proved the code ran, not that the feature worked the way it ships** — so we added a no-key end-to-end test that boots the real example through the real loader, plus packages/AGENTS.md rules on plugin export shape and optional-service access.
|
||||
Two integration mistakes broke ACP despite full unit coverage: a default export caused the Loader to discard `inject`, and a traced optional-service lookup failed across a shadow boundary. Hand-mounted tests bypassed both paths. The fixes added keyless real-Loader coverage and package rules for plugin exports and optional-service access.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -82,7 +82,7 @@ if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false) // ← direct glob
|
||||
|
||||
`ctx.reflect.get(name, false)` is a direct lookup in the global service store keyed by the isolate symbol — it ignores fiber topology entirely and finds the service. So from a top-level test the read works; from inside a real plugin fiber, reached via a shadow, it throws. The bridge is exactly the latter.
|
||||
|
||||
**Fix:** read the optional service through the same global store the bypass uses, but via the public `ctx.get(name)` — `this.ctx.get('sessionPersistence')` instead of `this.ctx.sessionPersistence`. `ctx.get(name)` is a direct lookup in the global service store keyed by the isolate symbol; it ignores fiber topology, so it resolves the backend regardless of which fiber or shadow the call arrives through. It is strict by default (an inactive/absent backend reads as `undefined`, which the existing guard rejects) — preferable to the `, false` overload, which would additionally skip the active-state check and could hand back a backend mid-teardown. The other reads in the resume path (`this.ctx.sessions`, `this.ctx.agents`) are fine — those *are* in `AgentLoop`'s `static inject`, so they sit in its fiber store and the ancestor walk finds them immediately.
|
||||
**Fix:** read the optional service with `ctx.get('sessionPersistence')`, which uses the global isolate-keyed store while preserving active-state checks. Direct property reads remain appropriate for services in the plugin's declared injection set.
|
||||
|
||||
## Why every test missed it (the real failure)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Post-mortem 0002: Filesystem snapshot tools were permanently disabled
|
||||
|
||||
Status: resolved
|
||||
|
||||
## Executive summary
|
||||
|
||||
The ACP example attempted to enable filesystem plugins conditionally with `disabled: !!js ...`, but Cordis evaluates JavaScript expressions only inside plugin `config`. The raw expression object was truthy, so the filesystem stack was always disabled. Snapshot refresh then accepted `UNKNOWN_TOOL` results as new goldens. The fix uses an explicit filesystem overlay and adds static-config and snapshot-result guards.
|
||||
|
||||
## Summary
|
||||
|
||||
The default ACP composition is intentionally bash-only because its sandbox cannot confine in-process filesystem providers. Filesystem snapshot scenarios still need `read`, `write`, and `edit`, so their plugins were placed in the default `cordis.yml` with a `disabled` expression intended to enable them only for full-access launches and snapshots.
|
||||
|
||||
Cordis Include parsed each `!!js` scalar into an expression object. The Loader recursively interpolated the plugin's `config`, but consumed entry metadata such as `disabled` directly. Every filesystem entry therefore saw a truthy object and remained disabled in every mode.
|
||||
|
||||
## Impact
|
||||
|
||||
Seven filesystem scenarios and the mixed workspace-edit scenario called tools that were absent from the registry. Their structured session logs carried `ToolNotFoundError` with code `UNKNOWN_TOOL`, while stdout rendered generic failed tool cards. The snapshot suite passed because both surfaces matched the refreshed fixtures; it proved deterministic replay of the regression rather than successful filesystem behavior.
|
||||
|
||||
The live confined default did not gain unintended filesystem access. A naive interpolation fix would have created that risk: permission presets update bash sandbox and approval state at runtime, but cannot mount, unmount, or confine the filesystem stack.
|
||||
|
||||
## Timeline
|
||||
|
||||
- PR #261 consolidated ACP compositions and refreshed the filesystem snapshots while introducing conditional filesystem entries.
|
||||
- All unit, coverage, snapshot, documentation, build, and hygiene checks passed.
|
||||
- Review of the refreshed filesystem goldens found generic failed cards and structured `UNKNOWN_TOOL` results.
|
||||
- A real Loader boot confirmed that every `disabled` value remained an expression object and every filesystem fiber was absent.
|
||||
|
||||
## Root cause
|
||||
|
||||
The implementation assumed `!!js` applied to an entire Loader entry. Its actual boundary is narrower: `Entry._resolveConfig()` interpolates only `entry.options.config`; `Entry.disabled` tests `entry.options.disabled` without interpolation. The YAML tag was syntactically valid, so loading produced no diagnostic.
|
||||
|
||||
The snapshot framework treated any deterministic transcript as valid behavior. Header pins verified the composed tool schemas, but the filesystem scenarios shared a pin from the default composition and therefore did not independently prove that their required tools were registered. Refresh rewrote the expected stdout and session logs before any semantic assertion rejected missing tools.
|
||||
|
||||
## Guardrails added
|
||||
|
||||
- Filesystem scenarios boot `fs.cordis.yml`, an explicit fixed full-access overlay with a paired replay config and its own request-header class.
|
||||
- [`AGENTS.md`](../../AGENTS.md) and the [Cordis primer](../cordis-primer.md#loader-configuration) state that `!!js` is valid only under plugin `config` and conditional composition uses overlays.
|
||||
- `verify-cordis-config` parses repository Cordis YAML and rejects expression nodes in Loader entry metadata, including include patches and inserted entries.
|
||||
- `dsh-acp-snapshot` rejects structured `UNKNOWN_TOOL` results in fresh runs and committed session fixtures before they can become accepted goldens.
|
||||
|
||||
## Lessons
|
||||
|
||||
- A syntactically accepted configuration value is not necessarily evaluated at that location; document and verify interpolation boundaries.
|
||||
- A snapshot refresh is fixture production, not correctness review. Semantic impossibilities such as a missing registered tool need assertions independent of the golden.
|
||||
- Permission controls must describe only the capabilities they actually govern. Composition-time filesystem access cannot follow a runtime bash-only preset safely.
|
||||
@@ -11,3 +11,4 @@ Every post-mortem opens with an **Executive summary**: one short paragraph a bus
|
||||
| # | Title |
|
||||
|---|---|
|
||||
| [0001](0001-acp-default-export-drops-inject.md) | ACP server crashed on connect: `export default` dropped the plugin's `inject` |
|
||||
| [0002](0002-js-expression-disabled-filesystem-tools.md) | Filesystem snapshot tools were permanently disabled by a literal `!!js` object |
|
||||
|
||||
@@ -11,6 +11,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 |
|
||||
| [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 |
|
||||
| [Interactive side sessions and merge-back](proposed/feature/2026-07-08-interactive-side-sessions.md) | 2026-07-08 |
|
||||
| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 |
|
||||
| [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 |
|
||||
|
||||
### Simplification
|
||||
@@ -19,8 +20,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
|---|---|
|
||||
| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
|
||||
| [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
|
||||
| [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 |
|
||||
| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
|
||||
| [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
|
||||
|
||||
### Architecture
|
||||
@@ -28,7 +27,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| Title | First proposed |
|
||||
|---|---|
|
||||
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
|
||||
| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
|
||||
|
||||
### Process
|
||||
|
||||
@@ -72,11 +70,15 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [The approval seam — one-shot permission decisions over a waterfall of answerers](implemented/feature/2026-07-06-approval-seam.md) | 2026-07-06 |
|
||||
| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
|
||||
| [The subprocess sandbox — confinement seam, native runners, escalation, and per-session modes](implemented/feature/2026-07-06-sandbox.md) | 2026-07-06 |
|
||||
| [MCP client plugin — connect to external MCP servers and bridge their tools](implemented/feature/2026-07-07-mcp-client-plugin.md) | 2026-07-07 |
|
||||
| [Plan mode — a logged per-agent session mode](implemented/feature/2026-07-07-plan-mode.md) | 2026-07-07 |
|
||||
| [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 |
|
||||
| [Background subagent tasks](implemented/feature/2026-07-08-background-subagent-tasks.md) | 2026-07-08 |
|
||||
| [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 |
|
||||
| [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 |
|
||||
| [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 |
|
||||
| [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 |
|
||||
| [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 |
|
||||
|
||||
### Simplification
|
||||
|
||||
@@ -101,6 +103,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 |
|
||||
| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
|
||||
| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
|
||||
| [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 |
|
||||
| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -124,6 +128,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 |
|
||||
| [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 |
|
||||
| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
|
||||
| [The background task runtime (`ctx.tasks`) and generic task control tools](implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
|
||||
| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 |
|
||||
| [Mandatory `User-Agent` attribution for provider requests](implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md) | 2026-06-21 |
|
||||
| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 |
|
||||
@@ -170,6 +175,9 @@ 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 |
|
||||
| [TypeScript Program-backed semantic gates](implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) | 2026-07-14 |
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo
|
||||
|
||||
## Classification
|
||||
|
||||
Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and [INDEX.md](INDEX.md) is **generated** from the tree in full (`pnpm run gen-rfc-index` rewrites it from each RFC's path, H1 title, and filename date; the gate fails when it is stale, and rejects an index-shaped row in this file). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the index is generated while this prose stays curated.
|
||||
Each RFC belongs to one path-encoded class from the closed set in `scripts/rfc-index.ts`; the classification gate rejects other folders. [INDEX.md](INDEX.md) is generated from paths, titles, and filename dates, and its freshness is gated. Adding a class requires updating the canonical set and this section. See the [classification](implemented/process/2026-06-20-rfc-classification.md) and [index-generation](implemented/process/2026-07-04-generate-rfc-index-tables.md) RFCs.
|
||||
|
||||
| Class | What it covers |
|
||||
|---|---|
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
# AGENTS.md — Implemented RFCs
|
||||
|
||||
These are RFCs whose decision has **shipped**. The repo-wide and docs-wide rules still apply ([root AGENTS.md](../../../AGENTS.md) § "Type safety and documentation", [docs/AGENTS.md](../../AGENTS.md)), and the in-file skeleton — including the proposal→implemented rewrite a lifecycle move owes — is [README.md § The file format](../README.md#the-file-format), gated by `verify-rfc-format`; this file adds one rule specific to this folder.
|
||||
These RFCs describe shipped decisions. Follow the [root instructions](../../../AGENTS.md), [documentation standard](../../AGENTS.md), and [RFC format](../README.md#the-file-format); `verify-rfc-format` gates the lifecycle-specific structure.
|
||||
|
||||
## Keep an implemented RFC current with what actually shipped
|
||||
|
||||
An RFC in `implemented/` describes a decision that is now **live code**. Keep its description of the shipped reality accurate: when the implementation later moves a file, renames a package or symbol, changes a config key/default/error code, or relocates a plugin, update the RFC in the **same change** that touches the code — exactly as you would a package README. A stale implemented RFC (pointing at a path that no longer exists, naming a package that was renamed, describing a structure that was refactored) is worse than no RFC: a future reader trusts it and is misled.
|
||||
|
||||
Update it **in place** to state the current truth. Do **not** leave the outdated text in and bolt on a "superseded / now actually…" note — that makes the document a changelog of its own drift and forces the reader to reconstruct the present from a pile of corrections. Write what is true now.
|
||||
Keep paths, symbols, defaults, and mechanisms current in the same change that alters them. Rewrite stale facts in place; do not append change history.
|
||||
|
||||
### This is not a license to rewrite the *decision*
|
||||
|
||||
Keeping the shipped-state description current is about **facts** (paths, names, structure, defaults) — not about silently flipping the **decision and its rationale** into a different one. The "new RFC" escape hatch is for **macro** changes — a genuine reversal of *what was decided* or its rationale — NOT for renames, moves, or structural relocations. A rename is always a fact to fix **in place**: leaving a package/symbol/path at its old name (even with a "was renamed to…" aside) only confuses a reader who greps the current tree for a name that no longer exists. So: the package was renamed, a symbol changed, a plugin moved, the decision is now realized through a different mechanism → edit this RFC to state the current names and structure. Only a reversal of *what was decided* → a new RFC and cross-link, per [rfc/README.md](../README.md) ("An RFC is never edited into a different decision").
|
||||
|
||||
When in doubt, ask whether a reader following this RFC to the code would land on something real. If not, it needs updating.
|
||||
Update factual realization in place. A reversal of the decision or its rationale requires a new RFC and cross-link; see [rfc/README.md](../README.md).
|
||||
|
||||
@@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log,
|
||||
|
||||
Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
|
||||
|
||||
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary.
|
||||
In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -19,6 +19,8 @@ In-session context injection (`context/message`, `steering/message`) renders as
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md).
|
||||
- Reasoning has a core home without provider-specific shapes.
|
||||
- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md).
|
||||
- Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFCs.
|
||||
- Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
|
||||
- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost.
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array.
|
||||
Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -18,4 +18,4 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required
|
||||
|
||||
- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy).
|
||||
- The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them.
|
||||
- The InferArgs mapping is regression-tested at the type level (expectTypeOf) after an early optionality bug shipped and was caught by review.
|
||||
- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug.
|
||||
|
||||
@@ -12,7 +12,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source
|
||||
|
||||
Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end.
|
||||
|
||||
Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested).
|
||||
Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ Status: implemented
|
||||
|
||||
Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically.
|
||||
|
||||
This is the last of the runtime-validation / error-taxonomy pieces and the one the user was most skeptical of, so it was deliberately built **last and in isolation**: the earlier PRs (arg validation, dev invariants) threw plain `Error`s with a `code` field, decoupled from any shared base, so this change is a pure upgrade and is independently revertible without unpicking them.
|
||||
|
||||
## Decision
|
||||
|
||||
A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams.
|
||||
@@ -21,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every
|
||||
- Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message.
|
||||
- One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge.
|
||||
- `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay.
|
||||
- Reverting this PR returns the earlier errors to plain `Error`+`code` form; nothing else in the stack depends on the shared base.
|
||||
- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text.
|
||||
|
||||
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->
|
||||
|
||||
@@ -12,9 +12,9 @@ This is distinct from "who provides vs. needs a capability at runtime", which Co
|
||||
|
||||
A swappable capability is **three packages**:
|
||||
|
||||
1. **Interface** — an abstract service + the vocabulary types, owning the `ctx.<key>` and depending only on cordis (e.g. `dsh-bash`: `BashExecutor`, `BashRunResult`, `BashTask`).
|
||||
1. **Interface** — an abstract service + the vocabulary types, owning the `ctx.<key>` and depending only on its vocabulary dependencies (e.g. `dsh-bash`: `BashExecutor`, `BashRunResult`, `BashProcess`).
|
||||
2. **Implementation** — a concrete subclass loaded as a plugin (e.g. `dsh-bash-local`: subprocesses, process-group kills, spill-file truncation). Sandboxed/remote backends are sibling packages implementing the same interface.
|
||||
3. **Consumer** — what the model and plugins see (e.g. `dsh-tool-bash`: the `bash`/`bash_output`/`bash_kill` tool schemas). Consumers `inject` the interface key and never import implementation types.
|
||||
3. **Consumer** — what the model and plugins see (e.g. `dsh-tool-bash`: the `bash` schema, with background handles registered into the generic task runtime). Consumers `inject` the interface key and never import implementation types.
|
||||
|
||||
Implementation and consumer then evolve independently: a sandboxed executor replaces `dsh-bash-local` without touching a tool schema.
|
||||
|
||||
|
||||
@@ -22,4 +22,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for
|
||||
|
||||
## Consequences
|
||||
|
||||
Double the adapter maintenance and double the key-gated e2e surface (both adapters cover V4 Flash and Pro across representative thinking/effort modes). Bought: a continuously-verified neutrality guarantee for the most leak-prone abstraction in the codebase, and a worked second example for adapter authors. The two share the core Config shape (`apiKey`/`baseURL`/`models`) so a deployment swaps mostly one line, but the reasoning knob differs — `dsh-llm-deepseek` takes `thinking`/`reasoningEffort`, `dsh-llm-pi-ai` takes a single `reasoning` level — so a swap translates that field. If the maintenance cost ever outweighs the verification value (e.g. once conformance tests from [architectural conformance](../../proposed/process/2026-06-11-architectural-conformance.md) cover the contract mechanically), retiring the twin to a single adapter + the conformance kit would be a new RFC superseding this one.
|
||||
The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding RFC.
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
> Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices.
|
||||
|
||||
## Problem
|
||||
|
||||
Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible.
|
||||
@@ -20,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca
|
||||
Key choices recorded here because they are durable, contested, and surprising:
|
||||
|
||||
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
|
||||
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
|
||||
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
|
||||
@@ -33,4 +31,4 @@ Format versioning: the header carries a `version`; `load` rejects any non-curren
|
||||
|
||||
## Consequences
|
||||
|
||||
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes [event-sourced sessions](2026-06-11-event-sourced-sessions.md)'s deferred "real persistence backend" and resolves its `TODO(review)` on the event vocabulary: persisting the log freezes its shape, and the `assistant/chunk` fidelity question is answered above (persist verbatim).
|
||||
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim.
|
||||
|
||||
@@ -37,4 +37,4 @@ Costs: `agent.inject()` while idle now writes three log lines instead of one, an
|
||||
|
||||
The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload.
|
||||
|
||||
The invariant also constrains where the loop may record an `error` event. A failure detected while a turn is open is appended INSIDE the turn (before `turn/end`); but a failure that surfaces once the turn is already closed — a rejecting `session/flush`, which runs as the post-`turn/end` durability checkpoint — has no in-turn position left. Appending an `error` there would land it past the last `turn/end`, exactly the crash-tail position a backend discards. So that post-turn failure is reported via the `agent/error` event and the logger only, never as a `SessionEvent`; the turn stays balanced and persistence keeps its buffered events for the next checkpoint. If durable operational diagnostics are ever needed, they belong on a separate telemetry channel, not the replayable session log.
|
||||
Failures detected during a turn are logged before `turn/end`. A later flush failure has no valid in-turn position, so it is reported through `agent/error` and logging rather than appended as a session event. This preserves a balanced replay log; durable operational diagnostics require a separate telemetry channel.
|
||||
|
||||
@@ -34,7 +34,7 @@ The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-f
|
||||
|
||||
Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer.
|
||||
|
||||
Read-before-write/edit and observed-state are policy, contributed by the `dsh-fs-policy` plugin through the `fs/*` event gate — NOT stored on `ctx.fs`. The provider seam offers an optional version guard on its mutations (`writeText`/`editText` take an optional expectation); the policy plugin decides that guard by listening on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`. The executor (`dsh-tool-fs`) passes the current tool execution context as the opaque event actor; the policy plugin derives the observed-state owner from it, normally `exec.agent.session`. `dsh-fs` treats the actor as opaque and never reads it; `dsh-tool-fs` never reaches into the policy plugin. Authorization is version freshness: any read records the file's version, and a later write/edit is authorized as long as the file is unchanged. (This RFC first placed the observed-state store on `ctx.fs`; the split to `dsh-fs-policy` on the `fs/*` event gate is decided by [the split-fs-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.)
|
||||
Read-before-write/edit and observed state belong to `dsh-fs-policy`, not `ctx.fs`. Through the `fs/*` event gate, the policy records versions per opaque actor and supplies optional mutation expectations; the provider enforces freshness atomically. `dsh-tool-fs` emits the events without depending on the policy. See the [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) RFCs.
|
||||
|
||||
## Package topology
|
||||
|
||||
|
||||
@@ -12,30 +12,28 @@ Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash ow
|
||||
|
||||
### 1. Queue-aware `Agent.cancel(reason?)`
|
||||
|
||||
A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a **turn-scoped cancellation marker** the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it `aborted`), and a later prompt cannot be batched into the cancelled turn. `whenIdle()` reaches post-cancel quiescence. ACP `session/cancel` maps to `cancel()`. The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt.
|
||||
`cancel()` is the single public stop primitive. It clears queued and steering input, aborts an in-flight step, and arms a turn-scoped marker checked at each turn boundary. A queued prompt therefore cannot start after cancellation or absorb later input. `whenIdle()` waits for post-cancel quiescence, and ACP `session/cancel` maps to this method. An idle cancel does not arm the marker.
|
||||
|
||||
### 2. `AgentHandle` async disposer
|
||||
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
`ctx.agents.create`/`resume` and `AgentFactory` return `AgentHandle = { agent, dispose() }`. Disposal is a consumer capability; an observer holding only `Agent` cannot tear it down. The caller fiber and factory provider also own the instance, and every path shares one memoized teardown: stop the loop, await quiescence and flushes, detach the agent and session, then unwind its scope. IDs become reusable when their registry entries detach. Config-created agents belong to the loop fiber; ACP stores and disposes each session handle.
|
||||
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
|
||||
Teardown order is load-bearing for durability. The session lifecycle and loop share one composite Cordis effect so LIFO disposal stops the loop and awaits `agent.done` before detaching the session. Sibling effects would dispose concurrently and could remove append hooks before the closing flush. Disposal notifications are contained so they cannot interrupt the chain.
|
||||
|
||||
### 3. Bash owner token in the seam
|
||||
|
||||
Background-task ownership moved from a `tool-bash` plugin-local `Map<string, Agent>` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.session.header.id` as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.session.header.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
|
||||
Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped.
|
||||
|
||||
## Verification
|
||||
|
||||
These invariants hold and are pinned by tests:
|
||||
|
||||
- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown.
|
||||
- `session/cancel` before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn.
|
||||
- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor).
|
||||
- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber.
|
||||
- ACP disconnect or session close leaves no registered agent or session-store entry, including when `session/load` races teardown.
|
||||
- Cancelling before a queued prompt starts prevents that prompt from running or absorbing the next prompt.
|
||||
- Reloading `dsh-tool-bash` does not let another session read or kill an existing background task because ownership remains on the executor.
|
||||
- Config-created agents remain loop-fiber-owned, so non-ACP demos need not manage handles explicitly.
|
||||
|
||||
## Session owner tokens are unique among live agents
|
||||
|
||||
The bash owner-token comparison relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split.
|
||||
The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The `Session` event log is the single source of truth ([event-sourced sessions](2026-06-11-event-sourced-sessions.md)), but the only view over it was `deriveMessages()` — a linear scan that filtered and transformed raw events into `Message[]`. This creates problems for session-history-manipulating plugins (compaction, tool-call result pruning, etc.). Without a central mechanism, each plugin would need to wrap `agent/request` to rewrite the message list — a pattern that suffers from listener-ordering fragility, provides no durable record of what was changed, and forces repeated changes to the core `deriveMessages()` whenever a new manipulation is added. A central hub in the `session` package, with a provenance-recording mechanism and enough flexibility for future plugins to manipulate session history through a stable API, lays a solid foundation for plugin development.
|
||||
The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -31,7 +31,7 @@ export type SurfaceOp =
|
||||
|
||||
### SurfaceManager: delta-based, not full rebuild
|
||||
|
||||
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding).
|
||||
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial delta folded on first access.
|
||||
|
||||
Delta processing is O(1) when no new events and O(new events) when new events arrive.
|
||||
|
||||
@@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
|
||||
|
||||
The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows).
|
||||
|
||||
Because the surface is the SOLE derivation path, a surface-eligible event that carries no `surfaceOp` marker is invisible to `deriveMessages()` — it would land in the log yet silently drop from history on resume/fork. `append`'s typed overload makes the marker mandatory for `SurfaceEventType` events at compile time, but only when the type argument is a SPECIFIC literal; when it widens to the `SessionEventType` union (a caller iterating raw events, e.g. `for (const e of log) append(e.type, e.data)`) the conditional rest collapses to optional and the compiler stops enforcing it. The marker requirement is therefore ALSO checked at runtime in two places: `append` itself throws on a marker-less surface-eligible event (covering the union-widening loophole), and the `Session` seed constructor re-checks the same invariant (alongside its seq-contiguity and JSON-serializability checks) so a seed/load/fork — which arrives as raw `SessionEvent[]`, bypassing `append` — is REJECTED rather than constructing a session that resumes with missing history. (No backward-compat path for surface-less logs: per the pre-release stance there is no persisted user data to preserve, so such a log is rejected, not upgraded.)
|
||||
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -39,4 +39,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever
|
||||
|
||||
## Consequences
|
||||
|
||||
The pre-extraction duplication was verbose but explicit — each backend read top-to-bottom. The coordinator adds one indirection (the hook seam) and one new concept (the opaque torn marker). This clears the bar because the centralized logic is the correctness-heavy part that was already being fixed twice, and the hook set is narrow (six methods, no inheritance). The hook surface was deliberately held to the minimum: the create-collision probe is NOT a separate hook — it folds into `loadStored(id) !== undefined`; there is no separate `materialize` hook (folded into `appendBatch` for atomicity); `list()` stays a backend method with no coordinator pass-through (listing needs none of the orchestration). The net effect is a reduction: one orchestration copy instead of two, the backends shrank by ~1200 lines of duplicated churn, and a future backend implements a handful of small primitives instead of copying the entire `session/event` → buffer → flush machinery.
|
||||
The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
|
||||
@@ -6,11 +6,11 @@ Status: implemented
|
||||
|
||||
The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today.
|
||||
|
||||
**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
|
||||
**Gap 1 — unbranded IDs in the bash seam.** `BashTask.id` and every executor/tool boundary used bare `string`, even though the generated value has the same `name-N` shape as default session ids. The model also returns this value through `task_id`, so confusing task and session ids was both type-correct and reachable.
|
||||
|
||||
The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole".
|
||||
|
||||
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map<string, Session>()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map<string, Agent>()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map<string, …>()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap<Agent, string>()`, `loadingIds = new Set<string>()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map<string, …>` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized.
|
||||
**Gap 2 — erosion of existing brands.** `CallId`, `SessionId`, and `AgentId` became bare strings in registry maps, public lookup parameters, ACP session tracking, and the persistence coordinator. Dropping a brand at a lookup boundary defeats its main protection.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -44,7 +44,7 @@ export function OwnerToken(id: string): OwnerToken {
|
||||
|
||||
### Why not typing `owner` as `SessionId`?
|
||||
|
||||
The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling.
|
||||
The executor treats ownership as opaque and must not depend on the session model. A distinct `OwnerToken` preserves that boundary while preventing raw strings or task ids from being passed as owners. `dsh-tool-bash`, which owns the access policy, performs the single conversion from `SessionId`.
|
||||
|
||||
## Out of scope / possible extensions
|
||||
|
||||
@@ -58,7 +58,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
|
||||
|
||||
## Verification
|
||||
|
||||
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts.
|
||||
`BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded through the executor, local implementation, and model-facing tool without adding a `dsh-session` dependency. Collections, public parameters, and exported signatures use the applicable brand for `CallId`, `SessionId`, `AgentId`, or `BashTaskId` rather than bare `string`; raw provider, ACP, and model inputs enter through the brand factory instead of scattered casts.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -6,24 +6,24 @@ Status: implemented
|
||||
|
||||
An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes.
|
||||
|
||||
The deeper problem was a **coupled front-door cluster** that lived at the leaf with nothing enforcing it. Choosing the ACP bridge over `ui-stdio` was not one swappable line: an ACP server must **drop the stdout console logger** (stdout is the JSON-RPC channel — a stray log corrupts the frames) and pre-create **no** agents (ACP `session/new` creates them on demand), whereas the stdio app needs a console logger and a pre-created `main`. (`timer` is the one infra plugin common to both — it writes nothing to stdout — so it belongs in the shared spine, not the cluster.) That coupling was enforced only by prose warnings in the leaf YAML. A leaf that wired a console logger into the ACP config was a one-line, comment-only mistake away — exactly the [stdout-purity footgun](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) the examples guarded by hand. The three `start.ts` files also duplicated the Loader-boot tail, the `.env` loader, and (for ACP) snapshot-mode branching and the stdin-dispose lifecycle.
|
||||
The leaf configs also owned a coupled front door. ACP requires stdout purity and creates agents through `session/new`; stdio requires a console logger and a pre-created `main`. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code.
|
||||
|
||||
## Decision
|
||||
|
||||
Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root).
|
||||
|
||||
- **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Service map): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension.
|
||||
- **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed.
|
||||
- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests.
|
||||
- **`@deepseek-ai/dsh-agent-spine-demo`** ([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle.
|
||||
- **`@deepseek-ai/dsh-stdio-demo`** ([packages/examples/stdio-demo](../../../../packages/examples/stdio-demo)) and **`@deepseek-ai/dsh-acp-demo`** ([packages/examples/acp-demo](../../../../packages/examples/acp-demo)) bake in their front doors. Stdio includes `ui-stdio`, a console logger, and `main`; ACP includes the bridge and JSONL persistence but no stdout logger or pre-created agent. Leaves may add plugins, but the safe composition is now the default artifact.
|
||||
- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-demo` / `dsh-acp-demo`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-demo ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests.
|
||||
- **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin).
|
||||
- **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins.
|
||||
- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-core`.
|
||||
- **echo-agent folds onto `dsh-stdio-demo`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins.
|
||||
- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`.
|
||||
|
||||
`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app.
|
||||
|
||||
### Amendment on implementation: `hmr` stays a leaf entry
|
||||
|
||||
The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-agent` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead:
|
||||
The proposal listed `hmr` among the stdio app's baked-in front-door cluster. Validating against the code, baking `hmr` into the `dsh-stdio-demo` package fights cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead:
|
||||
|
||||
1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier.
|
||||
2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function.
|
||||
@@ -38,18 +38,18 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a
|
||||
|
||||
## Verification
|
||||
|
||||
- Each example directory is `cordis.yml` (+ the acp `cordis.snapshot.yml`) + `README.md` + tests only — no `start.ts`, no infra preamble; `base.yml`/`base-core.yml`/`acp-tail.yml` are gone.
|
||||
- `demo:echo` / `demo:repl` / `demo:acp` run via the app-package `bin`s.
|
||||
- The new packages carry the per-file 100% coverage gate and a README like every `@deepseek-ai/dsh-*`. Each app package has a keyless **real-load-path** smoke that boots it through its `bin` + the cordis Loader (not a hand-built `ctx.plugin({...})` mount), guarding the `unwrapExports` export-shape bug class ([postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)).
|
||||
- The ACP snapshot **replay** transcript is unchanged: the boot restructuring preserved the plugin set + load order, so `pnpm run test:snapshot` stays green against the committed goldens with no re-record.
|
||||
- Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone.
|
||||
- `demo:echo`, `demo:repl`, and `demo:acp` invoke the app-package bins.
|
||||
- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- The ACP replay transcript remains unchanged because the plugin set and load order did not change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight.
|
||||
- **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight.
|
||||
- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan.
|
||||
|
||||
## Related
|
||||
|
||||
- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-core` and the `base*.yml` files are deleted.
|
||||
- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted.
|
||||
- Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
|
||||
- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# RFC: The background task runtime (`ctx.tasks`) and generic task control tools
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Background bash originally combined two responsibilities: the bash executor ran processes and also managed task ids, ownership, incremental reads, cancellation, completion listeners, and model-facing control tools. Adding background subagents required the same lifecycle and interaction contract. Implementing that contract independently for every long-running capability would duplicate isolation, cleanup, notification, and prompt behavior while teaching the model a different collect-and-stop protocol for each producer.
|
||||
|
||||
The task registry, control tools, and completion notices form one harness capability. Bash and subagents should supply execution-specific hooks without owning generic task behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
The `tasks/` package group owns background-task semantics:
|
||||
|
||||
- `@deepseek-ai/dsh-tasks` registers running work as `ctx.tasks` and owns task ids, authorization, snapshots, reads, cancellation, waiting, completion listeners, and cleanup.
|
||||
- `@deepseek-ai/dsh-tool-tasks` exposes `task_output`, `task_list`, and `task_kill`, injects completion notices, and supplies the background-task system-prompt guidance.
|
||||
|
||||
Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry.
|
||||
|
||||
`TaskService` is a concrete, process-local service. TODO(task-service-backend): separate its public contract from the implementation when a second backend defines the required lifecycle; a systemd-backed runtime is one plausible driver, but this PR does not speculate about its durability, reconnect, ownership, or observation semantics.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
The literal types live in the [task data-structure catalog](../../../core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id.
|
||||
|
||||
The producer hooks define three responsibilities:
|
||||
|
||||
- `cancel(reason?)` synchronously requests termination, is idempotent, and must cause `done` to settle.
|
||||
- `done` never rejects and settles only after the producer has released the task's resources.
|
||||
- Optional `readOutput()` returns the next consuming output delta. Omitting it declares a final-output task whose terminal result comes from `TaskOutcome.output`.
|
||||
|
||||
Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Producer-specific information such as an exit code or stop reason belongs in `detail`; the registry does not interpret it. Task kinds form a merge-extensible string union, and task ids are branded and generated as `<kind>-N`, with a counter per kind.
|
||||
|
||||
The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop.
|
||||
|
||||
Task registrations are not effects of the producer tool fiber. Reloading a tool or control-surface plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers.
|
||||
|
||||
## Authorization and owner lifecycle
|
||||
|
||||
Task ids are runtime-global and predictable, so every access is authorized by the registry. `get`, `read`, `wait`, and `kill` accept the calling `Agent`; `list` returns only tasks visible to that caller. An owned task is accessible only to the exact owning session. Unowned tasks are open to non-agent callers and die with the task service.
|
||||
|
||||
The snapshot stores the owner's branded `SessionId` for authorization, while lifecycle operations retain the exact live `Agent` instance. These identities serve different purposes: session equality grants access, but exact object identity selects cleanup and completion delivery. Reusing an agent or session id cannot redirect an old scope's cleanup or notices to a replacement.
|
||||
|
||||
The first task for an owner attaches one asynchronous effect to `owner.ctx`. Agent-scope disposal cancels that owner's live tasks, awaits their terminal records, and removes their snapshots. This effect survives producer reloads and joins the agent's existing quiescence boundary. The task service retains the effect disposer so service reload can detach callbacks from still-live agent scopes after global teardown.
|
||||
|
||||
For contract-compliant producers, `AgentHandle.dispose()` resolves only after owned background work has stopped. Work intended to outlive an agent must be started unowned; survival across runtime restarts requires a separate durable-job design.
|
||||
|
||||
## Service surface
|
||||
|
||||
`TaskService` provides:
|
||||
|
||||
- `start(spec)` for preflighted, atomic registration.
|
||||
- `get(id, caller?)` and `list(caller?)` for non-consuming snapshots.
|
||||
- `read(id, caller?)` for a consuming stream delta or an idempotent final result.
|
||||
- `kill(id, caller?, reason?)` for cancellation.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting.
|
||||
- `onTaskDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment.
|
||||
- `attachSurface(name)` for the control-surface availability fence.
|
||||
|
||||
`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing.
|
||||
|
||||
A producer loaded without any control surface would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachSurface()` for its lifetime, and `start()` fails before producer execution when no surface is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model surfaces can attach themselves without teaching the registry tool names.
|
||||
|
||||
## Model-facing control surface
|
||||
|
||||
`dsh-tool-tasks` registers three kind-independent tools with generic ACP cards:
|
||||
|
||||
- `task_output(task_id, wait?, timeout_ms?)` reads output and always appends `[status: ...]`. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Reads are non-blocking unless `wait: true`, whose timeout is defaulted and capped by plugin config. A wait timeout reports the still-running status and does not stop the task.
|
||||
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`, or `(no background tasks)`.
|
||||
- `task_kill(task_id, reason?)` requests cancellation immediately. The optional logged reason is forwarded to the producer. Terminal tasks report their existing status; a throwing producer cancel fails the call and leaves the task running.
|
||||
|
||||
Stream reads share one task-scoped consuming cursor because the owning model is the intended reader. A UI or multiple independent readers need a separate non-consuming observation API; sharing this cursor would let readers consume one another's output.
|
||||
|
||||
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
|
||||
|
||||
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown.
|
||||
|
||||
## Producer opt-in
|
||||
|
||||
Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash` and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it.
|
||||
|
||||
`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached surface, the runtime fence fails before execution.
|
||||
|
||||
## Producer integrations
|
||||
|
||||
The bash seam exposes `resolve`, `run`, and `start`. `start(spec)` returns a `BashProcess` with incremental reads, cancellation, exit facts, and a non-rejecting quiescence promise. The local executor retains live handles only so its own disposal can kill and join processes. Foreground callers continue to use `resolve` and `run` directly.
|
||||
|
||||
For background bash, `dsh-tool-bash` registers the calling agent as owner. Its hooks map `kill()` to cancellation, `done` to a completed or killed `TaskOutcome`, and `readOutput()` to the process's bounded incremental output plus spill and sandbox notices. Generic task tools own ids, status lines, listing, waiting, and completion notices.
|
||||
|
||||
For background subagents, `dsh-tool-subagent` creates a task-owned `AbortController` and begins provider startup inside the task starter. Cancellation aborts the same signal before or after provider readiness. `done` awaits both the child result and child disposal, maps completed output to a final result, maps abort to `killed`, and maps other stop reasons or infrastructure failures to `failed`. Intermediate child history remains in the child session and is not exposed through `readOutput()`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Per-capability control tools
|
||||
|
||||
Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, notification, and guidance while increasing the model's schema and protocol burden. One runtime keeps execution-specific behavior in producers without cloning the task lifecycle.
|
||||
|
||||
### An immediate abstract task-runtime backend
|
||||
|
||||
The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so extracting an interface before a second implementation exists would freeze the wrong boundary.
|
||||
|
||||
### Consumer-owned authorization or cleanup events
|
||||
|
||||
Consumer-owned checks invite inconsistent or missing isolation on each new surface. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook.
|
||||
|
||||
### Blocking output or a separate wait tool
|
||||
|
||||
Blocking by default would serialize the parent while background work runs. Waiting without reading would add another model call and schema without returning useful information. `task_output(wait: true)` makes blocking explicit and combines it with result delivery.
|
||||
|
||||
The wait uses the shared deadline primitives but not the generic tool-timeout policy. A wait timeout is a successful observation that returns `[status: running]`; the generic policy would replace it with a timeout error. No tool-call timeout controls task lifetime after a task id has been returned.
|
||||
|
||||
### Runtime-owned output sinks
|
||||
|
||||
A push sink would centralize buffering, but bash already owns bounded buffers, truncation, and spill files behind its executor seam. Pulling formatted deltas preserves that ownership. A durable backend that owns storage may justify revisiting the producer interface.
|
||||
|
||||
### Random ids, promotion, or lifecycle session events
|
||||
|
||||
Authorization, not unguessability, is the access boundary, and ids do not derive filesystem paths; sequential branded ids keep transcripts readable. Foreground-to-background promotion requires a user interaction contract the SDK does not prescribe. Starts, reads, and notices are already logged as tool and context events, so dedicated task session events would duplicate model-visible facts.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins preflight atomicity, per-kind ids, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance.
|
||||
|
||||
## Consequences
|
||||
|
||||
Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../cookbook/adding-a-tool.md) points producers to this contract.
|
||||
|
||||
Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs.
|
||||
@@ -12,7 +12,7 @@ The immediate prompt came from OpenRouter's [App Attribution](https://openrouter
|
||||
|
||||
- **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard.
|
||||
- **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers."
|
||||
- **Observed coding agents use product/version `User-Agent` strings, sometimes with environment context.** A non-exhaustive public-code survey found OpenAI Codex building `{originator}/{version} ({os} {os_version}; {arch}) ...` and carrying an `originator` header; Google Gemini CLI sending `GeminiCLI[-clientName]/{version}/{model} ({platform}; {arch}; {surface})` or a Cloud Code VS Code variant; Cline's Codex backend client sending `cline/{version} ({platform} {release}; {arch}) node/{nodeVersion}` plus `originator: cline`; SWE-agent setting `swe-agent/{version}` unless the user already supplied a header; Continue setting `Continue/{version}` for its ClawRouter provider plus `X-Continue-Provider`. Aider also appends `Aider/{version} +{website}` to browser-like user agents for web scraping, but that is not a model-provider request path. The pattern is not one exact format; it is product identity in `User-Agent`, with provider-specific side headers only where a provider/backend asks for them.
|
||||
- **Coding agents identify the product and version in `User-Agent`.** Public implementations vary in environment detail and provider-specific side headers, but product identity is the common contract; there is no universal exact format.
|
||||
- **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request."
|
||||
- **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header.
|
||||
- **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally.
|
||||
|
||||
@@ -22,7 +22,7 @@ Web access is a first-class capability seam following [the capability-seam RFC](
|
||||
|
||||
Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
|
||||
|
||||
Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below.
|
||||
Search and fetch are separate tools but one web-access seam. `ctx.web` owns provider selection, abort/error vocabulary, and deployment configuration for both parallel registries. Their request schemas and provider logic remain separate; the shared service is the product boundary for reaching the web.
|
||||
|
||||
`dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern:
|
||||
|
||||
@@ -64,9 +64,9 @@ flowchart LR
|
||||
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
|
||||
```
|
||||
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
|
||||
Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with platform-native `fetch` at the repo's Node floor, mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key.
|
||||
Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
|
||||
|
||||
`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages.
|
||||
|
||||
@@ -77,52 +77,42 @@ Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credenti
|
||||
```ts
|
||||
interface WebSearchProvider {
|
||||
readonly id: string
|
||||
status(): WebProviderStatus
|
||||
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
|
||||
available(): boolean
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
}
|
||||
|
||||
interface WebFetchProvider {
|
||||
readonly id: string
|
||||
status(): WebProviderStatus
|
||||
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
|
||||
available(): boolean
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
}
|
||||
|
||||
interface WebService {
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
|
||||
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
|
||||
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
|
||||
}
|
||||
|
||||
interface WebExecContext {
|
||||
readonly signal?: AbortSignal
|
||||
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
}
|
||||
```
|
||||
|
||||
`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`.
|
||||
The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`.
|
||||
|
||||
Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
|
||||
|
||||
## Provider status and selection
|
||||
## Provider availability and selection
|
||||
|
||||
Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls.
|
||||
Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls.
|
||||
|
||||
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
|
||||
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
|
||||
|
||||
`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
|
||||
|
||||
```ts
|
||||
type WebProviderStatus =
|
||||
| { readonly available: true }
|
||||
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
||||
```
|
||||
The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
|
||||
|
||||
Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
|
||||
|
||||
| Situation | Execution behavior |
|
||||
|---|---|
|
||||
| A configured provider id is registered and `status().available === true` | runs that provider |
|
||||
| A configured provider id is registered and `available() === true` | runs that provider |
|
||||
| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
|
||||
@@ -184,8 +174,6 @@ interface WebSearchRequest {
|
||||
}
|
||||
|
||||
interface WebSearchResult {
|
||||
readonly providerId: string
|
||||
readonly query: string
|
||||
readonly content?: string
|
||||
readonly sources: readonly WebSearchSource[]
|
||||
readonly truncated: boolean
|
||||
@@ -212,20 +200,17 @@ The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `l
|
||||
The seam request stays smaller than OpenCode's model-facing tool:
|
||||
|
||||
- `url`: required HTTP(S) URL.
|
||||
- `timeoutMs`: optional positive number capped by the provider.
|
||||
|
||||
The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
|
||||
The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
|
||||
|
||||
HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
|
||||
|
||||
```ts
|
||||
interface WebFetchRequest {
|
||||
readonly url: string
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
interface WebFetchResult {
|
||||
readonly providerId: string
|
||||
readonly url: string
|
||||
readonly statusCode: number
|
||||
readonly body: WebFetchBody
|
||||
@@ -239,7 +224,7 @@ type WebFetchBody =
|
||||
|
||||
`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair.
|
||||
|
||||
`WebFetchBody` is a CLOSED discriminated union owned by `dsh-web`, not a merge-extensible map. The merge-extensible pattern (`ContentBlockMap`) exists for variants that independent plugins introduce and the seam cannot foresee; body kinds are not that — `dsh-web` declares the kind, the fetch provider decodes it, and `dsh-tool-web` renders it, so a new kind is a coordinated change across three known packages, not a plugin extension. Keeping it closed buys compile-time exhaustiveness: consumers `switch` on `kind` ending in `default: assertNever(body, …)`, so adding a kind breaks compilation at every consumer that must render it (e.g. `tool-web`'s `html`→markdown vs `text` passthrough) until that arm is written. Each arm stays its own object literal even when the fields coincide today, leaving room for arm-specific fields (a future `pdf` body's `pageCount`, a `json` body's parsed value) without reshaping the type. Since the harness is unreleased, extending this closed union later is free (no migration, no compat shim).
|
||||
`WebFetchBody` is a closed discriminated union because body kinds require coordinated changes to the seam, provider, and tool rather than independent plugin extension. Exhaustive switches make a new kind fail compilation at every renderer until handled. Separate object arms leave room for kind-specific fields.
|
||||
|
||||
The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries.
|
||||
|
||||
@@ -257,11 +242,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
|
||||
|
||||
`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
|
||||
|
||||
`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
|
||||
`dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
|
||||
|
||||
Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
|
||||
|
||||
Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
|
||||
Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
|
||||
|
||||
The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives who
|
||||
provider dsh-fs-local local implementation of ctx.fs
|
||||
```
|
||||
|
||||
The model is **additive, not subtractive**: `ctx.fs` on its own is a complete, unconstrained text-storage seam — `read` reads, `write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text in the current content. There is no "先读后写", no version check, nothing to remove; the bare provider just does the I/O atomically. `dsh-fs-policy` is a plugin that *adds* constraints on top: observed-state, read-before-edit, and "write/edit must be based on the version you read". So removing `dsh-fs-policy` does not break `dsh-tool-fs` at the service-injection boundary; it removes the policy gate and leaves the bare provider behavior. The intended deployment stance is that a config loading the fs tools also loads `dsh-fs-policy`, so the user-facing behavior and prompt discipline are read-before-write/edit (the `coding-agent` and `acp-agent` demos wire the full stack). The bare-provider mode exists because the tool should not be method-coupled to the policy plugin, not because an unconstrained filesystem is the normal product stance.
|
||||
The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance.
|
||||
|
||||
`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`.
|
||||
|
||||
@@ -68,9 +68,7 @@ The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced
|
||||
|
||||
These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
|
||||
|
||||
**The two `fs/*` decision events are single-slot decision points, NOT a composable interception chain.** A waterfall listener that does not call `next()` short-circuits the rest of the chain (verified in [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts) — `waterfall` runs listeners around the final `next` thunk, and a listener that returns without calling `next()` reaches neither later listeners nor the tool's default thunk). `dsh-fs-policy` fully decides the write/edit expectation and does not call `next()`, so it occupies that one decision slot in the default deployment. This is deliberate: "what version basis does this mutation guard against" is a single decision, not an accumulation. The names (`fs/write-intent`, `fs/edit-intent`) say "produce the value", not "authorize", so they do not imply a stackable authorization chain. Genuinely composable interception (permission, audit, sandbox) belongs on the existing `tools/execute` waterfall, which every tool call already flows through — not on this fs version-decision slot.
|
||||
|
||||
**The occupant is decided by registration order — first-registered (or `prepend`ed) wins.** cordis dispatches waterfall listeners in registration order (`push`, or `unshift` for `prepend` — [vendor/cordis/src/events.ts](../../../../vendor/cordis/src/events.ts)), and the first non-`next()` decider short-circuits the rest. So the slot is **first-wins**, and `dsh-fs-policy` owning it rests on the default deployment convention: it is the decider registered for these events. The event shape does NOT itself guarantee "an unread edit is rejected" — a plugin that registers a looser `fs/edit-intent` decider BEFORE `dsh-fs-policy` (or with `prepend`) would decide first and bypass the `FS_NOT_OBSERVED` gate. That is the inherent property of a first-wins single slot, stated here so it is not mistaken for an enforced invariant. This RFC does not add a multi-policy composition mechanism; the implementation requirement is that `dsh-tool-fs` dispatches these waterfalls on every write/edit path and that a config wiring the fs tools loads `dsh-fs-policy` as the policy decider.
|
||||
**The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall.
|
||||
|
||||
The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure.
|
||||
|
||||
@@ -124,7 +122,7 @@ The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte un
|
||||
|
||||
The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment.
|
||||
|
||||
**`fs/observed` fires AFTER the mutation already succeeded**, via a plain `ctx.emit`. The event contract is intentionally narrow: an `fs/observed` listener MUST be synchronous and side-effect-only — `dsh-fs-policy`'s listener is a `WeakMap.set`, which cannot throw under normal operation and returns no promise. The tool does not guard the emit, so a listener that violates the contract by throwing would surface as the tool's `isError` result ([tools/index.ts](../../../../packages/core/tools/src/index.ts) — `ToolRegistry.execute` catches a tool throw into an error result) — reporting failure for a write/edit that actually happened. That is the price of keeping the event a plain fire-and-forget recorder: cordis `emit` does not await listener promises, so async or fallible audit/telemetry/listener work does not belong on this event. If layered or async observation is ever wanted, that is a new event with its own dispatch story.
|
||||
**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.
|
||||
|
||||
## Policy plugin contract (`dsh-fs-policy`)
|
||||
|
||||
@@ -154,7 +152,7 @@ This amends — does not reverse — [the split-fs-seam RFC](../simplification/2
|
||||
|
||||
## Verification
|
||||
|
||||
The decoupling and its semantics are pinned by tests: a bare-provider config (no `dsh-fs-policy`) boots the `dsh-tool-fs` root plugin and `read`/`write` (create and overwrite)/`edit` work against the real `dsh-fs-local` — an unread edit and an unread overwrite both succeed, proving the tool carries no `fileContext` dependency, while the same operations with `dsh-fs-policy` present are rejected `FS_NOT_OBSERVED` / gated `createIfAbsent`. A second `fs/edit-intent` listener registered after `dsh-fs-policy` is asserted NOT reached (first-wins short-circuit). A stale-read edit reports `FS_STALE_VERSION` through provider CAS, with `dsh-fs-policy` performing no `stat`; the tool's `stat` budget (read = 1, write = 0, edit = 0, on both paths) is asserted directly. Model-facing schemas stayed byte-for-byte unchanged, so snapshot transcript goldens are unaffected.
|
||||
Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This RFC adds those two inputs.
|
||||
|
||||
**These fields are NOT a new security boundary.** It is tempting to frame arbitrary-stdin / arbitrary-env as "dangerous, so gate who may use them" — but that framing is wrong, because a model driving the `bash` tool **already** has equivalent power through ordinary shell syntax: `FOO=bar cmd` sets an env var, a heredoc or `printf … | cmd` feeds arbitrary stdin. Adding `env`/`stdin` as seam fields grants the model no capability it lacks. In particular they cannot exfiltrate the harness's ambient credentials: the real control for that is the **credential scrub** in [dsh-bash-local](../../../../packages/bash/bash-local)'s `childEnv()`, which strips `*KEY*`/`*SECRET*`/`*TOKEN*` from `process.env` before the child sees it (see [docs/defensive-patterns.md](../../../defensive-patterns.md) § "Never hand untrusted output the ambient environment or predictable paths"). The scrub works regardless of these fields — a model cannot read a value that is not in the environment, and tool-call arguments are static JSON, never shell-evaluated, so a model cannot write `env: {LEAK: $DEEPSEEK_API_KEY}` and have it expand. So the security question is already answered by the scrub; this RFC is only about giving trusted in-process callers a clean way to pass a JSON payload + `CLAUDE_*` vars without routing them through model-visible shell text.
|
||||
`stdin` and `env` do not create a new model capability because ordinary shell syntax already supplies both. Ambient credentials are protected by `dsh-bash-local`'s child-environment scrub, not by hiding these seam fields; model tool arguments are static JSON and do not expand shell variables. The fields therefore serve trusted in-process callers, such as hook bridges, that need to pass structured input and `CLAUDE_*` variables without embedding them in model-visible shell text. See [defensive-patterns.md](../../../defensive-patterns.md) for the ambient-environment rule.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -14,18 +14,18 @@ Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecReq
|
||||
|
||||
Three deliberate choices:
|
||||
|
||||
1. **The model-facing `bash` tool simply does NOT expose `stdin`/`env` as parameters** — not as a security wall, but because bash syntax already covers the model's needs, so duplicating them as tool params would be redundant surface. [dsh-tool-bash](../../../../packages/bash/tool-bash)'s `bash` tool builds its `BashExecRequest` from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only; a model that includes `env`/`stdin` keys in its tool-call arguments simply has them ignored. A regression guard (`tool-bash` "does not forward env/stdin" tests) drives the real tool with those extra args and asserts the recorded request carries neither field — its purpose is to catch a future refactor that blindly spreads `...args` into the request and silently starts forwarding model input into the post-scrub `env` merge, NOT to defend a trust boundary. In-process plugins (the hooks bridges, native plugins) that construct a `BashExecRequest` directly set the fields; the seam imposes no access policy (consistent with how `owner` works — the executor stores but never interprets it).
|
||||
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly.
|
||||
|
||||
2. **`env` merges AFTER the credential scrub, so an explicit caller entry always wins** — even a credential-shaped name. This is correct because the scrub's job is narrow: stop the harness's *ambient* `process.env` credentials from leaking into a spawned command. A caller that explicitly sets a var has named a value it already holds (not the ambient secret), so the scrub is not a constraint on it. `childEnv(extra?)` layers `scrub(process.env)` → `ENV_OVERRIDES` (the model-friendly `TERM=dumb` etc.) → `extra`, last-wins.
|
||||
|
||||
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
|
||||
|
||||
`dsh-bash-local` spawns stdin as a `'pipe'` (writing the supplied bytes, then closing) ONLY when a caller set `stdin`; with none supplied it uses `'ignore'` — fd 0 → `/dev/null` — the exact pre-seam default. This distinction is observable and deliberate: a closed empty pipe and `/dev/null` are NOT the same file type (node's spawn pipe is an `AF_UNIX` socket, so `test -c /dev/stdin` holds for `/dev/null` but not for an empty pipe), so the no-stdin path — every model-driven call — must keep `/dev/null` rather than regress to an always-open pipe. Each branch's `stdio` tuple is a literal, which preserves the typed `spawn` overload that guarantees non-null `stdout`/`stderr`. When stdin IS written, a child that exits without reading makes the write fail EPIPE; that error is swallowed (the command's outcome rides on its exit code/output, not the write) so it never crashes the host or rejects `done`.
|
||||
`dsh-bash-local` creates a stdin pipe only when bytes are supplied; otherwise fd 0 remains `/dev/null`, preserving prior behavior. It writes the bytes and closes the pipe. `EPIPE` from a child that exits without reading is ignored because command exit and output determine the result.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
An earlier sketch of this work also proposed making `SENSITIVE_ENV_PATTERN` configurable. Validating against the code, that is **speculative and already subsumed**: `run.ts` documents a configurable whitelist as future work, and the new explicit `env` field — merged after the scrub — already gives a caller full control, including over credential-shaped vars. There is no current caller that needs to *broaden* the ambient scrub (the hazard runs the other way). Adding a config knob now would be a speculative surface with no consumer. If a real workflow ever needs to forward a specific ambient credential, the explicit `env` field is the supported path; a configurable scrub can be reconsidered then.
|
||||
**Configurable ambient-secret scrub.** Rejected as speculative. Trusted callers can explicitly provide required values after the scrub without weakening the default ambient protection.
|
||||
|
||||
## Consequences
|
||||
|
||||
A hook bridge builds a `BashExecRequest` with the hook's JSON payload as `stdin` and its `CLAUDE_*`/`PLUGIN_ROOT` vars as `env`, and runs it through the same `ctx.bash` everything else uses — no bespoke subprocess code, and the full process-group-kill / truncation / spill machinery for free. The model-facing attack surface is unchanged (the credential scrub, not these fields, is what bounds it), and the `bash` tool's request-building stays the single place that decides which fields a model call carries — guarded by a test that fails if a refactor starts forwarding model input. The vocabulary addition is documented in [docs/core-data-structures/bash.md](../../../core-data-structures/bash.md) (the `type-equiv` request/spec blocks) and the three bash-package READMEs.
|
||||
Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../core-data-structures/bash.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ The harness extends the agent loop through a Cordis event taxonomy (see [the mic
|
||||
|
||||
Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why.
|
||||
|
||||
This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the `hook/*` durable log, the bridges) build on.
|
||||
This vocabulary is the foundation for interception decisions, the durable `hook/*` log, and the Claude Code and Codex bridges.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd RFC work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
|
||||
|
||||
The filesystem tools did NOT honor this. `ctx.fs.resolve(path)` took no caller context, and `dsh-fs-local` resolved every relative path against a single `config.cwd` fixed at plugin load (`process.cwd()`). In the ACP demo that means `write foo.txt` and `bash cat foo.txt` resolve `foo.txt` against **different** directories — the fs tools against the server's launch dir, bash against the session's project dir. The two tools disagree about what "the current directory" is, which is a correctness bug the moment an editor opens any project other than the server's launch dir. It only appeared to work in the snapshot harness because that harness launches the child process in the same temp dir it passes as the session cwd, so the two coincide.
|
||||
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Add a **persisted, tool-private presentation channel** so a tool's `execute` can
|
||||
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
|
||||
```
|
||||
|
||||
`meta` is an opaque payload the core never interprets — typed `unknown` at every seam (the tool that produced it owns and narrows its shape). It MUST be JSON-serializable: the registry threads it onto the `tool/result` **session event**, and `Session.append` runtime-validates all event data with the existing `isJsonValue` predicate, so a non-serializable `meta` is rejected at the source. On replay the same `meta` is read back and handed to `presentResult` via a widened `ToolResult` (`{ content, isError, meta? }`). Because the payload lives in the event log, the diff reproduces on session reload / snapshot replay **for free** — the event-sourcing guarantee, not a re-computation. Typing `meta` as `unknown` (rather than a shared serializable-value type) keeps the tools core free of a dependency it would otherwise take just to name the type, and the runtime `isJsonValue` gate — not the static type — is what actually enforces serializability.
|
||||
`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core.
|
||||
|
||||
This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it.
|
||||
|
||||
@@ -31,7 +31,7 @@ This is the general shape ("a tool attaches durable result presentation"), not a
|
||||
Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**:
|
||||
|
||||
- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam.
|
||||
- `dsh-tool-fs` computes the contextual hunk from before/after and attaches it as `meta: { diffs: FileDiff[] }`. A contextual hunk is computed only when a before-version exists — edit always; write on overwrite; a create has no before, matching `claude-agent-acp`'s empty `structuredPatch` on create. But the completed `tool_call_update` is ALWAYS a `diff` card for a successful mutation: an ACP `tool_call_update.content` REPLACES the call's content, so rendering the model-facing result text would clobber the pending diff. So `write`'s result falls back to an args-derived whole-file diff (`oldText: null`) when it has no contextual hunk (a create, or an overwrite whose content is unchanged), and `edit` — which always changes content — always has a hunk. A failed/aborted/policy-rejected mutation applied nothing, so it carries no `meta` and falls through to the generic error rendering (its message must show).
|
||||
- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
|
||||
|
||||
### 3. The bridge renders a `diff` result card
|
||||
|
||||
@@ -39,7 +39,7 @@ Per the [capability-seam split](2026-06-13-capability-seams.md), the storage bac
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Hand-rolling or vendoring the diff algorithm.** Computing hunks-with-context is a solved problem with sharp edge cases (grouping, context coalescing, the trailing-newline marker). Rather than hand-roll it, `dsh-tool-fs` takes a runtime dependency on the npm [`diff`](https://www.npmjs.com/package/diff) package (a `^9.0.0` range, exact-pinned by the lockfile; it ships its own types) and uses its `structuredPatch`. The repo's default is to vendor Cordis-framework source, but that policy is about the *framework*; a leaf tool package taking a small, well-known, self-typed utility dependency is the same shape as `dsh-acp` depending on `@agentclientprotocol/sdk`. Vendoring a diff algorithm would be re-implementing a battle-tested one for no benefit — the [pre-release "foundation over blast radius"](../../../../AGENTS.md) reasoning does not argue for re-deriving standard algorithms. The dependency's output is normalized in one small module (`packages/fs/tool-fs/src/diff.ts`).
|
||||
**Hand-rolling or vendoring the diff algorithm.** Contextual hunks have established edge cases, so `dsh-tool-fs` uses the typed [`diff`](https://www.npmjs.com/package/diff) package and normalizes `structuredPatch` output in one module. The repository's vendoring policy applies to its framework source, not every leaf utility.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
|
||||
### Producer mapping
|
||||
|
||||
- `dsh-tool-fs` read → `generic` (`kind:'read'`, a follow-along `location`); write → `diff` (`oldText:null`); edit → `diff` (`oldText:old_string || null`, `newText:new_string ?? ''`). This mirrors `claude-agent-acp`'s `toolInfoFromToolUse` Read/Write/Edit arms field-for-field.
|
||||
- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` and `bash_output`/`bash_kill` → `generic`.
|
||||
- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` → `generic`. The generic `task_*` controls own their own generic cards.
|
||||
- `dsh-tool-todo` → `generic`.
|
||||
|
||||
### Terminal fallback ownership
|
||||
|
||||
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `<name>/SKILL.md` or `<name>.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack.
|
||||
|
||||
This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation.
|
||||
This decision adds the provider capability without a model-facing `ls`/`list` tool or skill-discovery change. Those consumers require separate UX, prompt, and policy decisions.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -36,7 +36,7 @@ Broken or disappeared children may be represented as `type: 'other'` without `ve
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately.
|
||||
**Add a model-facing list tool with the seam.** Rejected because its prompt, schema, and rendering contracts are independent of the provider primitive.
|
||||
|
||||
**Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends.
|
||||
|
||||
|
||||
@@ -24,21 +24,21 @@ The assembled system prompt had four defects, all of one family: facts the harne
|
||||
|
||||
### Prompt variables
|
||||
|
||||
Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real.
|
||||
Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique.
|
||||
|
||||
`dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own.
|
||||
|
||||
### Persona as the order-0 section
|
||||
|
||||
`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona.
|
||||
`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`.
|
||||
|
||||
### Tool guidance ownership
|
||||
|
||||
Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools.
|
||||
Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior.
|
||||
|
||||
### The subagent conversation-history descriptor
|
||||
|
||||
`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
|
||||
`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -56,10 +56,10 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship
|
||||
|
||||
## Shipped invariants
|
||||
|
||||
- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path.
|
||||
- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload.
|
||||
- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw.
|
||||
- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request.
|
||||
- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path.
|
||||
- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes.
|
||||
- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw.
|
||||
- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Two gaps shared one root. First, provider KV caching (DeepSeek context caching) is prefix-based — a request pays full price only for the tokens after the longest stored prefix it matches — yet nothing in the request pipeline stated, checked, or measured prefix stability: every registered [`PromptSection`](../../../../packages/core/system-prompt/src/index.ts) happened to be static, the tool set happened not to change mid-session, no listener happened to rewrite requests. A single time-interpolating section would have silently multiplied context cost, and no test or metric would have moved. Second, and deeper: the session log — the system's single source of truth — could not actually answer *what the model saw*. It recorded every message but never the system prompt, the tool schemas, or even which model; the mutable `agent/request` waterfall handed listeners the whole `GenerateOptions` to rewrite per call; replay equivalence was therefore a property of the plugin population, not of the design.
|
||||
The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.
|
||||
|
||||
The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this RFC answers is how to get that discipline without giving up event-sourcing.
|
||||
|
||||
@@ -20,22 +20,22 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
**Messages.** `Session.deriveMessages()` is cached: each surface node is projected exactly once, when first seen, through the public per-node function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.
|
||||
|
||||
**The header.** The request's non-history half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas, and the session prefix (`messagePrefix`, below) — is logged session state, in canonical form (empty system/tools/prefix ≡ absent). Two log-only, turn-enclosed events in dsh-session carry it: `request/header`, a full snapshot with reason `'initial' | 'resume' | 'fallback'`, and `request/header-delta`, an amendment (`SystemDelta`: a common-prefix/suffix line trim; `ToolsDelta`: name-keyed added/removed/changed; `config`: replaced whole; `messagePrefix`: replaced whole, an empty array encoding the transition to absence — an arm the loop never exercises in practice, kept for codec totality). The pure trio `foldRequestHeader` / `diffHeader` / `applyHeaderDelta` reconstructs; the live session tracks the fold with the same lazy cursor as the message cache. Snapshots anchor the fold where a fold needs anchors — conversation birth and process boundaries — and each loop instance appends one on its first request (`'initial'` when the log has none, `'resume'` otherwise, even when nothing changed: the boundary itself is a recorded fact, and cross-restart drift becomes attributable while an unchanged header resumes byte-identical). Deltas are an encoding optimization with a safety valve, never a correctness dependency: the writer verifies `applyHeaderDelta(prev, delta)` reproduces the new header exactly and records a `'fallback'` snapshot when the encoding cannot express a change (a pure tool reordering), so a well-formed log always folds.
|
||||
`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot. `request/header-delta` encodes system changes by common-prefix/suffix line trim, tools by name-keyed additions/removals/changes, and config or prefix by full replacement. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot for unrepresentable changes such as pure tool reordering.
|
||||
|
||||
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed.
|
||||
Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written.
|
||||
|
||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||
**`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written.
|
||||
|
||||
**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step.
|
||||
**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.
|
||||
|
||||
### The MiniCode shape: adopted, with the provenance arrow inverted
|
||||
|
||||
What survives from `LLMClient`: the conversation is maintained, not rebuilt — one projection per message, ever; requests advance append-only; resets happen only for a system-prompt/tool change, a config change, or compaction, each now a *logged* fact. What is deliberately inverted: MiniCode's client is the source of truth and its event stream derives from client appends (`on_event(MessageAdded)`), which suits an advisory event stream. Here the log is contractual — persistence, crash recovery, fork seeding, transcript rendering, and the snapshot harness all replay it — and it carries strictly more than a message list (turn/step boundaries, raw chunk streams, tool-call pairing, provenance, log-only records), so a message-list client cannot generate it. The arrow therefore points log → client: the conversation state IS the log plus two cached folds inside `Session` (messages, header), and the "client" the loop talks to is the session itself. What the inversion buys over the original: the reconstruction is *checkable* against an independent record on every request — MiniCode's client has nothing to check itself against.
|
||||
Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and provenance. `Session` caches message and header folds derived from that log, making every request independently checkable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above.
|
||||
- **A stateful transmission client mirroring the log** (a `PromptPrefix` class holding committed/open message zones with an append/editTail/reset vocabulary, the log pushed into it per event): behaviorally equivalent on the happy path, but it duplicates conversation state outside the session, needs transactional rollback around listener seams, keeps an unlogged content-shaping surface (`editTail`) whose divergence the invariant must specially allow, and still cannot answer "what header did the model see" from the log. Dissolving it into the session's own caches plus logged header events made every one of those problems unrepresentable instead of guarded. (PR #162 is the archaeology of this alternative, three designs deep.)
|
||||
- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths.
|
||||
- **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records.
|
||||
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
|
||||
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule.
|
||||
|
||||
The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)).
|
||||
Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)).
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -21,7 +21,7 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolving the provider at `apply` time and throwing when absent (a load-order requirement)** — the first implementation, rejected after review reproduced the failure above. Documenting the requirement ("list backends first") would pin a guarantee the Loader does not make.
|
||||
- **Resolve the provider at `apply` time and throw when absent** — rejected because "list backends first" would claim a Loader ordering guarantee that does not exist.
|
||||
- **Retrying the lookup (poll until the provider appears)** — converges eventually but invents a private readiness protocol beside the one the framework already has (effect registration + disposal); it also cannot notice a provider LEAVING, so HMR would strand a tool whose wording describes a disposed backend.
|
||||
- **Section-only subagent wording, lazily resolved at assemble time** — tolerates any load order too, but moves tool-choice guidance out of the DESCRIPTION, contradicting the ownership rule the prompt-variables RFC establishes (per-tool semantics and when-to-use belong in the description). Reactive registration keeps the description authoritative AND order-free.
|
||||
- **Keying wording off the provider NAME instead of the provider object** — `providerName` is itself config, so a renamed provider silently gets the wrong words; deriving from the resolved provider's own `inheritsParentContext` cannot drift.
|
||||
@@ -29,6 +29,6 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis
|
||||
## Consequences
|
||||
|
||||
- Consumers deriving state from a named provider react to `subagent/provider-added`/`-removed` instead of reading the registry at `apply` time; `dsh-tool-subagent` is the reference implementation.
|
||||
- **The two emits carry asymmetric failure semantics, deliberately.** `provider-removed` fires inside the registration's disposer and is delivered with PER-LISTENER containment (the service's `emitLifecycle`, not raw `ctx.emit`, which halts dispatch on the first throw): a throwing subscriber is logged, never starves a later mirror into holding a stale tool, and never disrupts the backend fiber's teardown — dispose reaches quiescence. `provider-added` propagates: it fires at registration time, where a throwing listener unwinds the yielded rollback — the same fail-loud register-time semantics as the system-prompt registries. The run-time backstop bounds what a stale mirror could cost anyway: `start()` re-resolves the provider by name per run, so a tool that outlived its provider fails that call cleanly instead of dispatching into a dead backend. The [events catalog](../../../cordis-catalog/events.md) carries the exact signatures, and the [producer/consumer map](../../../event-producer-consumer.md) shows `dsh-subagent` emitting and `dsh-tool-subagent` consuming both events.
|
||||
- **Addition fails loud; removal is contained per listener.** An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown. `start()` still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the [events catalog](../../../cordis-catalog/events.md) and [producer/consumer map](../../../event-producer-consumer.md).
|
||||
- **A window where the tool is absent.** Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's `tools/change` emit keeps prompt assembly current.
|
||||
- **Two waiting fibers sharing a `toolName` is an invalid config caught late.** If two loads of `dsh-tool-subagent` name different providers but the same `toolName`, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives. `TODO(subagent-dup-toolname)` in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop.
|
||||
|
||||
@@ -12,8 +12,6 @@ Timeout handling was drifting apart across the tool-bearing capabilities, and th
|
||||
|
||||
Each new external-process or network tool re-derived the same four things — clamp the requested value, start a timer, fuse the timeout with upstream cancellation, and distinguish "timed out" from "cancelled" on the way out — and the fusion and reason-recovery are exactly the parts that are easy to get subtly wrong (web_fetch's `signal.reason` dance is evidence). At the same time, the *termination* each performs is irreducibly different: bash kills an OS process group (work runs in a child process, outside this runtime, reachable only by signal), while web aborts an in-process `fetch` (undici tears down the socket). There is no single mechanism that can stop all of them.
|
||||
|
||||
The two reference agents surveyed converged on the same split. Codex models "what will end this exec early" as one value (`ExecExpiration`, an enum fusing timeout and a cancellation token) whose `wait_with_outcome()` returns `TimedOut | Cancelled`, while the actual `kill_process_group` lives outside it — and that abstraction is reused *only* across the exec family, with MCP, model-stream, and guardian each keeping their own bespoke `tokio::time::timeout`. Claude Code shares nothing: bash and ripgrep each own a private SIGTERM→SIGKILL kill and distinguish timeout from cancellation by throwing distinct error types, while file I/O has no timeout. Both confirm the boundary drawn here: the timing-and-classification half is worth sharing within a family of like-terminated operations; the termination half is not shareable and stays in each capability.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-timeout` lives under `packages/util/` (peer to `dsh-brand`) and owns the *timing and classification* half of timeout; the *termination* half — the hard kill — stays in each capability's implementation. It is a library of pure functions, **not** a cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. There is deliberately no central "timeout service" that would have to know how to stop every capability's work — that knowledge is exactly what a microkernel keeps out of shared layers, and what Codex's exec-only `ExecExpiration` scope demonstrates.
|
||||
@@ -57,7 +55,7 @@ export function deadline(
|
||||
export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined
|
||||
```
|
||||
|
||||
`deadline` is `AbortSignal.any([upstream, <timeout controller>])` with three things the standard library does not give: a typed, identifiable `TimeoutReason` on the timeout abort (native `AbortSignal.timeout()` yields a fixed `TimeoutError`, indistinguishable across timeout kinds), an internal `timeoutMs <= 0` "no timeout" sentinel for backend-owned background work, and a `Symbol.dispose` cleanup that works with both `using` and manual disposal. `AbortSignal.any` is a Node ≥ 20 primitive; it is the single mechanism that fuses two abort sources into one, adopting the reason of whichever fires first. External request hints validate as positive finite numbers via `clampTimeout` before they reach `deadline`; `0` is not a model-/plugin-facing "disable timeout" value. When `timeoutMs <= 0` and no upstream signal is present, `deadline()` returns a never-aborting signal plus a no-op disposer so callers keep one call shape. `TimeoutReason` is an internal classification reason: providers translate it into seam-specific public errors or result fields before returning to callers. `timeoutOf`'s optional `code` scopes classification to the caller's own deadline: when the `upstream` is itself a deadline (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if it fires first, and an unscoped match would misreport the outer timeout as the inner capability's own; scoping to `code` reads a foreign timeout as an ordinary upstream cancel.
|
||||
`deadline` fuses an upstream signal with a timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout.
|
||||
|
||||
### The division of labor
|
||||
|
||||
@@ -76,7 +74,7 @@ The signal only *notifies*; termination is always the listener's job, and the li
|
||||
### How each capability consumes it
|
||||
|
||||
- **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error.
|
||||
- **bash** — `resolve()` stays a pure request-to-spec step: it clamps with `clampTimeout(request.timeoutMs, config.timeoutMs, config.maxTimeoutMs, 'bash-local: request.timeoutMs')` and carries `request.signal` through unchanged. Foreground `run()` owns the timeout: `using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')`, then `runBash` receives only `d.signal`. `runBash` no longer owns any timer — it listens for abort and runs its existing SIGTERM→grace→SIGKILL process-group kill, and its `SpawnSpec`/`SpawnOutcome` no longer carry `timeoutMs`/`timedOut`/`aborted` (the executor classifies from the deadline signal instead). `run()` computes `timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined` and `aborted = d.signal.aborted && !timedOut`, so the public seam booleans (`BashRunResult.timedOut`/`aborted`) are mutually exclusive — the shared deadline reports the cause that first cut the command short, and the `code` scope keeps a nested outer deadline from being misread as bash's own timeout. Background `start()` creates no deadline and forwards only the upstream signal, so background tasks stay timeout-free; a task's killed-vs-completed status reads its own `spec.signal.aborted`.
|
||||
- **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -95,4 +93,4 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode
|
||||
|
||||
**A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule.
|
||||
|
||||
**Keep bash's two independent triggers (`killTimer` + `onAbort`) rather than fusing.** Rejected for the convergence goal: fusing into one `deadline` signal removes bash's bespoke timer and gives every capability one shape. The trade-off is that bash's `timedOut`/`aborted` booleans become first-abort classifications rather than independent facts that can both be true when timeout and user abort race before process close. That is acceptable because the result reports the cause that first cut the command short; the termination action stays the same uniform SIGTERM→grace→SIGKILL kill. Note the deliberate non-alignment with Codex: Codex forks its kill by outcome (timeout → immediate SIGKILL; cancel → SIGTERM + 50 ms grace → SIGKILL), whereas the fused signal drives one uniform `kill()` for both, matching Claude Code's unified bash kill. Splitting the kill by `timeoutOf` is possible later if a need appears; there is none now.
|
||||
**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the existing SIGTERM-to-SIGKILL termination path remains unchanged.
|
||||
|
||||
@@ -50,16 +50,15 @@ The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespac
|
||||
searchTimeoutMs: 30000
|
||||
```
|
||||
|
||||
Keeping the tool name out of this plugin's config is deliberate: a budget keyed by a free-text tool name could be mistyped (`web_fech`) and then silently apply to nothing. Declaring `timeoutMs` on the tool makes that failure class structurally impossible — the enforcer reads `ctx.tools.get(exec.name)?.timeoutMs`, and `exec.name` is the tool being dispatched, so the lookup always resolves and there is no unknown-name path to warn or throw about. `timeoutMs` is validated positive-finite by `defineTool` at definition time. For a tool that declares a budget the listener arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')`, swaps the derived signal onto `exec` for the downstream dispatch, restores the caller's own signal afterward, and returns a structured `TOOL_TIMEOUT` result when `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches. A tool with no declared budget delegates unchanged.
|
||||
Timeouts live on tool definitions rather than a free-text name map, eliminating misspelled unused policy. `defineTool` validates a positive finite budget. During dispatch the enforcer derives a deadline signal, restores the caller signal afterward, and converts its own expiry into `TOOL_TIMEOUT`; tools without a budget pass through unchanged.
|
||||
|
||||
Signal replacement is by **in-place mutation of `exec.signal`**, not by passing a new object to `next()`. Cordis's waterfall `next()` ignores any arguments handed to it and re-invokes downstream listeners with the shared payload array (`vendor/cordis/src/events.ts`), so the documented cordis idiom — mutate the shared object, then delegate — is the only mechanism that reaches dispatch. The plugin restores `exec.signal` to the caller's original in a `finally` so `tools/post-execute` never sees this plugin's (possibly already-aborted) deadline signal.
|
||||
|
||||
`timeout-policy` owns both uses of the `TOOL_TIMEOUT` code: the internal deadline code passed to `deadline()`/`timeoutOf()` (scoped so a nested outer deadline reads as an ordinary cancel) and the structured tool-result error code. Its replacement result is:
|
||||
|
||||
```ts ignore-check
|
||||
function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
|
||||
function toolTimeoutResult(timeoutMs: number): ToolExecutionResult {
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
|
||||
isError: true,
|
||||
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
@@ -75,11 +74,11 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
|
||||
|
||||
`web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
|
||||
|
||||
`dsh-web-fetch-local` keeps a provider-level timeout (`timeoutMs`/`maxTimeoutMs`) as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
|
||||
`dsh-web-fetch-local` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
|
||||
|
||||
`bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable.
|
||||
|
||||
`read`, `write`, `edit`, `todo_write`, `bash_output`, and `bash_kill` do not opt into tool-call timeout: they are local filesystem or short registry/session operations where a deadline would be best-effort only or unnecessary.
|
||||
`read`, `write`, `edit`, `todo_write`, `task_list`, and `task_kill` do not opt into tool-call timeout. `task_output` owns its bounded wait because a wait timeout is a successful live-status result, not a tool failure.
|
||||
|
||||
A future model-facing grep/glob tool can be implemented on top of `ctx.bash` without importing `@deepseek-ai/dsh-timeout`: it forwards `exec.signal` to `ctx.bash`, and declares its own `timeoutMs` (from its plugin's config) for the enforcer to apply. If bash-local's backend timeout becomes a problem for such a tool, the bash seam can later add a caller-owned-deadline mode; that is outside this cut.
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4170d9f63773ef998bc5393fabc98a2b9fcba2fa
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7d80fb572ea36010df4829121fabe8cf66944f6b
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: b177af24e988c6a314db522b8de0d1c09e30464f
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0b964e8a748e4adcc32c017957e5294a3f258365
|
||||
|
||||
@@ -23,12 +23,12 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
|
||||
|
||||
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay goldens, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
|
||||
|
||||
### The serving surface is a plugin: the two packages ui/jsonrpc + ui/jsonrpc-agent
|
||||
### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo
|
||||
|
||||
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `ui/acp-agent` pattern — the serving surface is itself a plugin:
|
||||
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
|
||||
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md) (`@deepseek-ai/dsh-jsonrpc-agent`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
|
||||
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.
|
||||
|
||||
@@ -40,13 +40,13 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms.
|
||||
|
||||
@@ -54,7 +54,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c
|
||||
|
||||
### Naming lineage
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-agent` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`.
|
||||
`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`.
|
||||
|
||||
## Disposition of worker-style plugins
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放 golden、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。
|
||||
|
||||
### 对外服务接口也是插件:ui/jsonrpc + ui/jsonrpc-agent 两包
|
||||
### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两包
|
||||
|
||||
确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——对外服务接口本身也是插件:
|
||||
确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
|
||||
|
||||
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
|
||||
- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
|
||||
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。
|
||||
|
||||
@@ -40,13 +40,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真
|
||||
|
||||
### 构建管线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包和 3 个原生运行时 wheel 包;裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR 添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。
|
||||
|
||||
@@ -54,7 +54,7 @@ exe“必须显式配置”的硬语义不变;零配置体验由包装层恢
|
||||
|
||||
### 命名血统
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。
|
||||
`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。
|
||||
|
||||
## 工作线程插件
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ The design can be skimmed as seven choices:
|
||||
| Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result |
|
||||
| Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary |
|
||||
|
||||
The rest of this RFC expands those choices in dependency order. It first explains the Cordis mechanics, then scope routing, creation and session commit, tools and prompts, subagents and workflows, and finally the checks that make the reasoning executable.
|
||||
The rest of this RFC expands those choices in dependency order: Cordis mechanics, scope routing, creation and session commit, tools and prompts, subagents and workflows, then executable checks.
|
||||
|
||||
The [July 8 RFC](2026-07-08-agent-scope-contexts.md) remains the contributor contract. The separate [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns `persona`, `toolFilter`, and `maxDepth`; this document discusses only how their setup fits the lifecycle.
|
||||
|
||||
@@ -328,7 +328,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt
|
||||
|
||||
### Generated artifacts keep public contracts aligned
|
||||
|
||||
The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, and type-equivalence blocks are generated or freshness-gated from source. `verify-scoped-dispatch` keeps the declared scoped-event set aligned with runtime invariant coverage.
|
||||
The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, type-equivalence blocks, and scoped-event resolver map are generated or freshness-gated from source. The [TypeScript semantic-gates RFC](../process/2026-07-14-typescript-program-backed-semantic-gates.md) owns Program construction, semantic event discovery, and resolver-generation rules.
|
||||
|
||||
Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentRe
|
||||
|
||||
Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask.
|
||||
|
||||
The bridge advertises ACP config options instead of session modes. `sandbox-mode` exists only when the mounted bash executor reports sandbox capability, and `approval-policy` exists only when `ctx.approval` is composed. Each option is an independent select whose current value is the session event fold over the composition default. `session/set_config_option` validates against the owning domain vocabulary and writes through `setSandboxMode` or `setApprovalPolicy`. An open-turn switch appends immediately; an idle switch is overlaid in the response and anchored at the next turn start. Until that anchor it is memory-only and a crash reverts to the durable fold. ACP session modes are deliberately not modeled because one mode list cannot represent these orthogonal knobs and config options are the forward protocol surface. Runtime model selection remains outside this decision; `AcpConfig.model` is connection-wide.
|
||||
When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide.
|
||||
|
||||
The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved.
|
||||
|
||||
@@ -40,7 +40,7 @@ The precise supported and deferred protocol rows live in [`packages/ui/acp/acp-f
|
||||
|
||||
**Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only.
|
||||
|
||||
**Represent sandbox and approval as ACP session modes** — rejected. They are independent composable settings, while a single current mode is mutually exclusive. ACP config options represent both without a cross-product and match the protocol's forward direction.
|
||||
**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2.
|
||||
|
||||
**Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity.
|
||||
|
||||
@@ -50,7 +50,7 @@ Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple
|
||||
|
||||
The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, runtime model selection, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. The feature checklist records these as unsupported rather than silently accepting them.
|
||||
|
||||
An idle config selection is truthful in the live response but not durable until the next turn anchors it. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe.
|
||||
An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -24,33 +24,33 @@ Three decisions, each elaborated in its own section below:
|
||||
|
||||
`ToolRegistry` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention.
|
||||
|
||||
**Wire tool list = the registry's contribution before cooperative assembly.** The registry feeds assembly through a mode-aware provider: `'native'` contributes every capability visible to that assembly scope, `'code'` contributes only `run_code`, and `'both'` contributes both. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the final presentation is logged and reconstructable. The reserved transport is not a capability: it lives outside global/scoped registration and restriction layers, cannot be registered or shadowed there, and cannot be named by `ctx.tools.restrict()`. The mode governs this provider's input to assembly; other direct `systemPrompt.tools()` providers own their schemas, and the trusted assembly waterfall owns the returned wire list.
|
||||
**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions.
|
||||
|
||||
**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it.
|
||||
|
||||
**The SDK prompt section.** Under `'code'` and `'both'` the registry registers one lazy prompt section (`tools:sdk`, in the 100–199 tool-guidance order band) whose thunk regenerates, for each assembly scope, a TypeScript declaration of every visible end-capability tool plus fixed usage instructions. It uses the same visibility resolver as lookup and execution, so scoped grants and shadows appear while restricted globals disappear; the reserved `run_code` transport itself is excluded. The thunk emits tools in lexicographic name order, so an unchanged visible set produces byte-identical text.
|
||||
**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
|
||||
|
||||
**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition.
|
||||
|
||||
**Codegen.** A pure `jsonSchemaToTs(schema)` module inside `dsh-tools` (sibling of `json-schema.ts` — `schemas()` and the SDK are two projections of the same store) maps the JSON-Schema subset the `defineTool` DSL emits (object/string/number/boolean/array, `properties`, `required`, string `enum` → literal union, nested objects, array `items`, `description` → JSDoc) to a TS type literal. It is **total**: any construct outside that subset (`$ref`, `oneOf`/`anyOf`, `integer`, future MCP shapes, …) degrades to `unknown` without throwing. Because `ToolSchema.name` is an arbitrary string, the SDK is declared as one object constant — `declare const tools: { "some-mcp-tool"(args: …): Promise<string>; bash(args: …): Promise<string>; … }` — quoted keys make every name reachable with no sanitization or alias-collision logic. Typing is advisory (the runtime executes type-stripped JS); the instructions say so.
|
||||
**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution.
|
||||
|
||||
### The run_code tool and the dispatch bridge
|
||||
|
||||
Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`:
|
||||
|
||||
1. **Builds the bindings**: the bridge owns a **run-scoped `AbortController`** whose signal follows `exec.signal` (an outer cancel propagates in) and which the bridge itself fires the moment the run settles for any reason — completion, program exception, `computeMs`/`maxWallMs` expiry, worker exit. For every visible capability tool, the binding is an async function that (a) checks the run signal before and after, (b) **JSON-normalizes the argument** — a `JSON.parse(JSON.stringify(args))` round-trip, rejecting that one call with a descriptive `Error` when the value does not survive (`BigInt`, circular structures) — because the seam's structured-clone boundary is wider than JSON while the session log accepts only JSON, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, parent: exec.token, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) ``, (e) appends a `tool/code-dispatch` session event, and (f) maps the result: success → the text-block contents joined as a `string` (non-text blocks become placeholders), `isError` → **the binding rejects** with an `Error` carrying the result text. The child's readonly `parent` is only the outer execution's frozen, property-free token, so commit-style observers can correlate outcomes without receiving a mutation path into the live `run_code` wrapper. Every sub-call still traverses the full pipeline under its own immutable identity and registry-assigned token. The run signal, rather than the bare outer one, lets budget expiry abort an in-flight sub-tool instead of orphaning it. Rejection gives programs ordinary `try/catch` and `Promise.all` failure semantics rather than a bespoke result envelope.
|
||||
1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
|
||||
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime.
|
||||
3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` settles, whether by fulfillment or rejection, the bridge fires the run-scoped abort (cancelling any in-flight sub-dispatch and abandoning queued-unstarted ones), then **awaits the dispatch queue's drain before returning or propagating**, per the dispose-to-quiescence rule in [defensive patterns](../../../defensive-patterns.md): an aborted in-flight sub-call still settles and logs its `isError` `tool/code-dispatch` event *inside* the open turn, and nothing can append after `run_code` settles. A successful result then returns one text block — the captured console/stdout output followed by the rendered return value (if any) — plus a `meta` payload (capped logs, dispatch count) for presentation. A fulfilled run with `result.error` throws a `CodeRunFailedError extends HarnessError` (`code: 'CODE_RUN_FAILED'`, message = the error kind and text plus captured logs so the model can self-correct); a backend rejection propagates through the same registry error boundary. Both become structured `isError` tool results.
|
||||
3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles.
|
||||
|
||||
**Sub-call `additionalContext` is suppressed, deliberately.** A `tools/post-execute` hook may attach `additionalContext` to a call; for loop-dispatched calls the loop buffers those and appends each as a `context/message` only after the step's `tool/result`s, preserving call/result adjacency. A sub-dispatch result's `additionalContext` has no such safe outlet from inside a running `run_code`: injecting immediately would land a `context/message` between the parent's `tool/call` and its `tool/result` (breaking the adjacency the buffering exists to protect), and `PostToolDecision.additionalContext` is singular where a program may produce many. The MVP therefore drops sub-call `additionalContext`, pinned by a test and stated in the hooks bridge's docs; the follow-up (a plural context channel or loop-level sub-dispatch buffering) is deferred until a real hook needs it through Code Mode.
|
||||
**Sub-call `additionalContext` is omitted.** Injecting it during `run_code` would break parent call/result adjacency, while one program can produce many contexts. Supporting it requires a plural channel or loop-level sub-dispatch buffer.
|
||||
|
||||
**Concurrency: serialized, enforced by the binding.** The bindings are async, so a model writing `Promise.all([tools.a(…), tools.b(…)])` starts both immediately — concurrent dispatch would be the default, while the tool contract carries no concurrency-safety metadata (the open parallel-execution TODO). Each `run_code` invocation therefore owns a dispatch queue and every binding call chains onto it, so even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order; when the run settles, queued-but-unstarted dispatches are abandoned. Lifting this per tool remains tied to tools declaring themselves concurrency-safe.
|
||||
**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata.
|
||||
|
||||
**Presentation.** `run_code`'s render intent is decided here per the [render-intent RFC](../../implemented/architecture/2026-07-02-tool-render-intent-union.md): `presentCall` → a `generic` card, `kind: 'execute'`, title = the program text, `rawInput` = the same program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not.
|
||||
|
||||
### Observability: `tool/code-dispatch`
|
||||
|
||||
Each sub-dispatch appends one session event, declared by `dsh-tools` via `SessionEventMap` declaration merging (the map is merge-extensible for exactly this; `todo/write` is the log-only precedent): `tool/code-dispatch` with `{ parentCallId, subCallId, name, arguments, isError, resultSummary }` — `arguments` being the bridge's JSON-normalized value, the very one dispatched, so the append cannot fail on payload shape. It is log-only — `deriveEventMessage()` ignores unknown event types by design, so sub-calls never re-enter model context — but persistence and UIs get every call. As a log event it carries JSDoc prose but **no `@mode` tag** (that vocabulary belongs to cordis bus events; the persistence-catalog generator hard-errors on one) and lands in the regenerated `docs/persistence-catalog.md`; appends happen inside `run_code`'s execution, so the turn-enclosure invariant is satisfied by construction. A `run_code` execution arriving without `exec.agent` (the loop always supplies it; direct programmatic calls may not) still runs and simply skips event logging, exactly as the `ToolExecution` contract allows.
|
||||
Each sub-dispatch appends a log-only `tool/code-dispatch` event containing parent and child call ids, tool identity, normalized arguments, and result summary. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event.
|
||||
|
||||
### The code-runtime seam
|
||||
|
||||
@@ -63,7 +63,7 @@ Each sub-dispatch appends one session event, declared by `dsh-tools` via `Sessio
|
||||
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout.
|
||||
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
|
||||
|
||||
Per explicit-over-implicit at seams, the request spells out everything the runtime acts on; defaulting (timeouts, caps) is the implementation's validated config, never a hidden `??` inside `run()`. Consumption uses the loop's optional-backend idiom: Cordis has no optional injection — every `inject` entry gates activation — so a static `inject` on the registry would hold `ctx.tools` (and every tool plugin behind it) hostage to a code runtime existing even under `mode: 'native'`; instead the registry reads `ctx.get('codeRuntime')` at use time, exactly as `agent-loop` consumes `sessionPersistence`, with absence failing loud in the provider thunk. The seam has concrete divergence on both axes: the worker-thread substrate can be replaced by a container or microVM implementation, and the TypeScript language contract can be paired with a language-specific SDK and runtime. `dsh-tools` consumes only the interface and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template.
|
||||
Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator.
|
||||
|
||||
### The worker-thread runtime
|
||||
|
||||
@@ -73,36 +73,27 @@ Per explicit-over-implicit at seams, the request spells out everything the runti
|
||||
2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable.
|
||||
3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals and a capturing `console` shim, so top-level `await` and `return` work and the program's completion value is the run's `value` (structured-cloneable values cross as-is; anything else is replaced by its `util.inspect` rendering, documented).
|
||||
4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code.
|
||||
5. **Enforce caps — two independent budgets, because the peer is hostile.** The compute budget (`computeMs`) meters the worker's *measured busy time* via `worker.performance.eventLoopUtilization()` polling — not host-side "is an RPC pending" bookkeeping, which a program defeats by firing an un-awaited call at a slow tool and then spinning hot while the host thinks it is waiting. Measured busy time cannot be gamed: a hot loop accrues it whether or not a dispatch is in flight, and a program genuinely awaiting a slow tool accrues none, so a long-running `bash` sub-call still does not kill an innocent run. The wall ceiling (`maxWallMs`) never pauses for anything and backstops what busy-time cannot see (a program awaiting a promise nobody will resolve). Budget expiry, `signal` abort, and run completion all funnel into `worker.terminate()`, which ends hot synchronous loops too (measured; this was `node:vm`'s unfixable gap); the failure reports which budget fired. Heap overflow surfaces as the worker's OOM exit → `error.kind: 'worker-exit'`. Log and value sizes are capped by config, truncation marked in-band. All caps are validated config fields with defaults (`computeMs: 60_000`, `maxWallMs: 600_000`, `maxLogBytes: 65_536`, `maxValueBytes: 32_768`, `maxOldGenerationSizeMb: 512`), changeable from `cordis.yml`.
|
||||
5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. Expiry, cancellation, and completion terminate the worker. Heap exits and truncation are reported explicitly; compute, wall, heap, log, and return-value caps are validated configuration.
|
||||
6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../defensive-patterns.md).
|
||||
|
||||
### Trust posture
|
||||
|
||||
The worker runtime is **containment, not a security boundary**. Model code in the worker can reach Node globals — `fetch`, `process` (with an empty env), dynamic `import()` of built-ins — so a deliberately adversarial program has ambient authority comparable to what the harness's own `bash` tool grants every model turn: `dsh-bash-local` runs arbitrary model-written commands with the host filesystem, network, and a scrubbed-but-populated environment. One asymmetry runs the other way: `worker.terminate()` ends the thread, not OS processes a program may have spawned via `node:child_process` — weaker than `bash-local`'s process-group kill for direct children (equivalent for double-forked daemons, which survive both); the wall-clock ceiling bounds the worker itself, and orphan cleanup is the same deployment-level concern it is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, and a separate isolate. A `node:vm` executor with no containment would need explicit unsafe acknowledgement; imposing that ceremony on the better-contained worker while bash needs none would be posture theater. A deployment that needs a hard boundary (untrusted multi-tenant input) needs it for bash too; that is a future `isolation: 'container'` backend, and the `isolation` descriptor lets deployments distinguish backends.
|
||||
The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. Code Mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend.
|
||||
|
||||
### What the model sees
|
||||
|
||||
The `tools:sdk` section carries the `.d.ts` plus fixed instructions: the program is the body of an async TypeScript function (erasable syntax only — no `enum`/namespaces; type annotations are advisory); call tools as `await tools.name(args)` (quoted access for exotic names); a failed tool call **rejects** with an `Error` carrying the tool's error text — catch it to handle and continue; calls run **sequentially** even under `Promise.all`; emit results via `return` and/or `console.log`, and only that curated output returns to the context — intermediate tool results never do. That last line is the payoff the whole design serves: output-side context cost becomes the model's own editorial decision. On the input side the `.d.ts` is not free — for a large tool surface it can rival the native JSON schemas it replaces (and `'both'` pays for the two side by side) — but it is prefix-stable, so provider prefix caching amortizes it; the win is workload-dependent and the RFC claims no more.
|
||||
The SDK instructs the model to write an async erasable-TypeScript body, call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching.
|
||||
|
||||
## Consequences
|
||||
|
||||
The design consists of the `dsh-code-runtime` interface package, the `dsh-code-runtime-worker` backend, and the `dsh-tools` presentation and dispatch integration.
|
||||
|
||||
Shipped surface:
|
||||
|
||||
- **The seam**: `packages/code-runtime/` — `@deepseek-ai/dsh-code-runtime` (abstract `CodeRuntime`, the vocabulary above, `ctx.codeRuntime`) and `@deepseek-ai/dsh-code-runtime-worker` (the worker-thread backend, every cap a validated config field). Rows in the service map, capability-seams graph, config catalog, and cordis catalog.
|
||||
- **The registry surface**: `ToolRegistry`'s `mode` config, mode-aware wire contribution, lazy `tools:sdk` section and reserved `run_code` transport, `jsonSchemaToTs`/`renderToolsSdk` (exported), the dispatch bridge and `CodeRunFailedError`, and the `tool/code-dispatch` log event (declaration-merged into `SessionEventMap`, regenerated into the persistence catalog; `run_code` in the tool catalog).
|
||||
- **The composed surface**: the `tools` config forwards through `agent-core` and both app packages (`stdio-agent`, `acp-agent`); `demo:code-mode` boots each UI example's `code-mode.cordis.yml` overlay (the worker runtime + `mode: 'code'` over the base tree); every program sub-dispatch resolves the same scoped capability view and re-enters the complete tool pipeline with an immutable link to its enclosing transport execution.
|
||||
- **Interactions inherited by deployments**: a `toolOrder` naming native tools rejects every assembly under `'code'` (update or drop the order config when switching modes); restrictions can hide end capabilities but cannot remove the registry-owned presentation transport, while assembly listeners may rewrite the final model-visible surface and own its protocol integrity; sub-call `additionalContext` is dropped by the bridge (a plural context channel is deferred until a real hook needs it through Code Mode); sub-dispatch stays serialized until tools can declare concurrency safety — the same metadata the native parallel-dispatch TODO waits on.
|
||||
Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, and the bridge does not propagate per-call `additionalContext` until those contracts are designed for Code Mode.
|
||||
|
||||
## Testing
|
||||
|
||||
What the suites pin, per tier:
|
||||
|
||||
- **Unit — worker runtime** (real workers, no mocks): output/value capture and log-source attribution; error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM); the two budgets from both sides (a hot loop behind an un-awaited pending dispatch dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`); binding-bridge hostility (junk/forged port traffic incl. non-object messages and forged `log`/`done` cap bypass attempts, unknown names, duplicate ids, post-settlement replies, `__proto__`/`constructor`/`toString` binding names); structured-clone fallback and cap truncation; `env` emptiness verified from inside a program; dispose-awaits-exit. A real-load-path e2e runs the BUILT package under plain `node` so the worker entry resolves both unbuilt (tsx) and built — the published-artifact guard from [docs/testing.md](../../../testing.md).
|
||||
- **Unit — registry integration**: the codegen table (DSL subset, quoted names, `unknown` degradation, byte-identical determinism); provider contribution per mode (`'native'` capabilities, `'code'` exactly `[run_code]`, `'both'` capabilities + `run_code`); reserved-name, restriction, scoped shadowing, authoritative assembly transformation, and `toolOrder × mode` invariants; missing-runtime / wrong-language loud failures; full-pipeline and opaque parent-token behavior for sub-dispatches; serialization non-overlap (a probe tool records enter/exit under `Promise.all`); abort aborting the in-flight sub-dispatch and abandoning queued ones; binding rejection on `isError` and on JSON-unrepresentable arguments; `CodeRunFailedError` → structured `isError` carrying kind + logs; `tool/code-dispatch` payloads (JSON-normalized arguments identical to what dispatched); `deriveMessages()` ignoring the event; sub-call `additionalContext` suppression; HMR safety.
|
||||
- **e2e (with-key, self-skips)**: a real model under `mode: 'code'` composes two bash calls in one program (`examples/coding-agent/tests/code-mode.e2e.ts`) — every logged `request/header` carries exactly `[run_code]`, the dispatch events land under the parent call, the file the program wrote exists, and the final answer is the curated output.
|
||||
- **Snapshot (keyless replay)**: goldens for a `run_code` turn under `'code'` and `'both'` (`code-mode-turn`, `both-mode-turn`), each its own header-pinning class — the SDK section text, the collapsed header tool list, the dispatch events, and the result card are committed and replayed.
|
||||
- **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node.
|
||||
- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, omitted `additionalContext`, and HMR cleanup.
|
||||
- **With-key e2e:** A real model composes two bash calls in one program; the test verifies the collapsed request header, correlated dispatch events, resulting file, and curated answer.
|
||||
- **Snapshot:** The `code-mode-turn` and `both-mode-turn` fixtures pin the SDK section, header tool list, dispatch events, and result card.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -132,7 +123,7 @@ What the suites pin, per tier:
|
||||
|
||||
**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Contained by module boundaries inside the package (`ts-types.ts`, `code-mode.ts` beside `schema.ts`/`json-schema.ts`/`presentation.ts`) and by the seam: everything substrate-shaped lives behind `ctx.codeRuntime`.
|
||||
|
||||
**Structured-clone limits at the binding boundary.** The seam's clone boundary admits values JSON does not (`Date`, `Map`, `BigInt`), and the session log accepts only JSON — left unhandled, a sub-call could execute and then fail at `tool/code-dispatch` append time. Closed by the bridge's JSON-normalization step (§ the dispatch bridge): what does not survive the round-trip rejects that binding call before dispatch, so every executed sub-call is loggable by construction. The seam itself keeps the wider structured-clone contract (it is about the port, and stated so a future binding producer cannot discover it in production); consumers with stricter payload needs enforce them at their own boundary, as the bridge does. Non-text sub-result content is reduced to placeholders — a known MVP limitation, recorded in the SDK instructions.
|
||||
**Structured-clone values can exceed JSON.** Tool bindings therefore JSON-normalize arguments before dispatch, ensuring every executed call can be logged. The lower-level runtime keeps its wider port contract, while stricter consumers validate at their boundary. Non-text sub-results become placeholders.
|
||||
|
||||
**Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block.
|
||||
|
||||
That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.)
|
||||
Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card.
|
||||
|
||||
## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create`
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text
|
||||
|
||||
### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam
|
||||
|
||||
Compaction is a **surface mutation**, not a request transform — and that distinction is the seam it belongs on. The loop's request lifecycle, per step, is: assemble the system prompt → open the step → derive the message history from the surface → run the `agent/request` waterfall → call the model. An earlier cut wedged compaction into the `agent/request` waterfall, which forced two problems: (1) the loop had already derived `messages` from the *stale* surface, so the listener had to mutate the surface and then *re-derive* and overwrite `request.messages` — a double-derive whose only purpose was to undo the premature first derive; and (2) `agent/request` also carries downstream-injected context a listener might have added to `request.messages`, which compaction cannot act on (it can only compact the surface), inviting the confusion of measuring tokens compaction can't shed.
|
||||
Compaction mutates the session surface, so it runs before the step opens and before messages are derived. `agent/request` remains a call-config transform and never needs to rebuild history after a surface change.
|
||||
|
||||
The fix is a dedicated loop seam, **`agent/pre-step`** (`@mode serial`), fired by the loop *after* system assembly and *before* the step opens (`step/start`):
|
||||
|
||||
@@ -46,15 +46,13 @@ messages = session.deriveMessages() ⟵ single derive, reflects the compaction
|
||||
request = waterfall agent/request ⟵ pure request transform (hooks, model switch)
|
||||
```
|
||||
|
||||
This makes the layering correct *by construction*: compaction mutates the surface, the loop derives **once** from the result (no double-derive), and at `pre-step` the assembled `messages` do not yet exist — so a listener structurally *cannot* see or be expected to act on downstream-injected context. `agent/request` reverts to a pure request transformer. Firing the seam **before** `step/start` (not inside the open step) is load-bearing for crash-safety: compaction's log-only `compact/*` records and its replacement node land *outside* any step, so the honest log structure a crash leaves (a dangling `compact/start` sitting before the synthetic `turn/end` that turn-repair appends) holds without a half-open step to reconcile. The seam is `serial` (awaited, in registration order), not `parallel`: a listener mutates the surface as a side effect — there is nothing to transform or return — and serial isolates listeners from each other so two surface-mutating listeners can never interleave their `session.append`s. Cordis `serial` does bail early if a listener returns a bail value, so `agent/pre-step` listeners are typed/documented to return `void` and must not use that bail channel as a semantic veto surface.
|
||||
|
||||
This **amends** the original RFC's claim of "NO changes to `dsh-agent-loop`; compaction is a pure plugin." That claim was load-bearing for a wrong design — reusing `agent/request` was the mistake. Per the pre-release "foundation over blast radius" stance, adding the correct seam (one event declaration in `dsh-agent`, one awaited emit in the loop) beats preserving a no-change boast that locked in the double-derive.
|
||||
The loop derives messages once after `agent/pre-step`. Running before `step/start` keeps compaction records outside any half-open step, simplifying crash repair. The seam is awaited and serial so surface mutations cannot interleave; listeners return `void` and do not use Cordis bail values as vetoes.
|
||||
|
||||
### Retention is turn-agnostic; tool-pairing balance is the only structural guard
|
||||
|
||||
Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed.
|
||||
|
||||
So retention does **not** protect the in-flight turn, and turn boundaries play no role in it. `compactIfNeeded` walks the surface nodes tail→head, summing per-node token estimates, and retains the smallest tail-run of **whole units** whose total reaches `retainTokens`; everything older is compacted (head-anchored — see below). A *unit* is either a whole closed step (its `assistant/message` plus its `tool/result`s) or a single no-step node (a pre-step `user/message`, inter-step `steering/message`, or injection `context/message`). The walk rounds toward retaining *more*: when the raw token cutoff lands mid-step, it extends the retained side head-ward until the cut before the retained node is **tool-pairing balanced**. The single structural guard is therefore **tool-pairing balance** — a region's edges are balanced cuts on the *surface* (no unanswered `tool-call` crosses either edge), so a compacted region never splits a step's tool-calls from their `tool/result`s (which would produce a transcript every provider rejects). The check is decided over the surface linked list, **not** the log's `step/*` markers: a compaction lands a replacement node at a high log seq whose surface position is the head, so a log-position scan mis-reads its neighbours — `dsh-session` exports `isToolPairingBalanced(nodes, events, beforeSeq)` for the surface-anchored check. `compactRegion` enforces it strictly, throwing on a boundary that would split a step.
|
||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||
|
||||
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
|
||||
|
||||
@@ -62,7 +60,7 @@ A runaway turn thus compacts exactly like any other history: its early *closed*
|
||||
|
||||
### Head-anchoring: one auto checkpoint, always at the head
|
||||
|
||||
`compactIfNeeded` always anchors the compacted range at the surface **head** (`nodes[0]`). After a first compaction lands a summary node at the head, the *second* compaction's range starts at that summary node and re-summarizes it together with the steps accumulated since — so the surface holds **at most one** auto-generated checkpoint, always at the head, re-consolidated each cycle (the backend's checkpoint-merge prompt makes this a cheap incremental merge — see below). This is *why* `CompactionResult.shadowedRange` is a **surface-position span, not a numeric seq interval**: after a replace lands a fresh high-seq summary node at an older range's position, `start` can be numerically **greater** than `end`. The range is resolved positionally (index into the ordered node list and slice), and `shadowedSeqs` is the authoritative set in surface order. (Manual `compactRegion` may target any aligned mid-range and so *can* leave several checkpoints; the checkpoint framing does not claim everything after it is recent.)
|
||||
Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. `shadowedRange` is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. `shadowedSeqs` records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints.
|
||||
|
||||
### Approximate convergence invariant
|
||||
|
||||
@@ -85,7 +83,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
|
||||
|
||||
### Checkpoint framing + incremental merge (backend-private)
|
||||
|
||||
The landed `user/message` is not the raw summary: the backend wraps it in a checkpoint preamble (so a resuming model reads it as established background, not a fresh request) and `<compacted-summary>…</compacted-summary>` tags. The tags make a prior checkpoint detectable on the next cycle, and the summarization prompt then instructs the model to *merge it in place* (preserve still-true facts, drop stale) rather than re-summarize verbatim — a cheap incremental merge that needs no extra log/event machinery. The raw, unframed summary stays on the `compact/summary` provenance event. This framing is entirely a **backend HOW decision** — the contract only promises "a single replace `user/message` carries the (possibly framed) summary; the raw summary lives on `compact/summary`." A template or remote backend may frame differently or not at all.
|
||||
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary.
|
||||
|
||||
### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy
|
||||
|
||||
@@ -121,7 +119,7 @@ Two failure paths, both documented:
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`dsh-compact-basic`): the whole-unit retention walk, the convergence-invariant throw, both failure paths (`compact/end` with/without `error`), head-anchoring producing a non-monotonic `shadowedRange`, decline-on-open-tail, crash-orphan inertness, and the **runaway-turn regression** — a single oversized open turn compacts its early closed steps (proven to fail on the layer-2 protection it replaced). Driven through the real `dsh-invariants` plugin and the real Loader/inject path.
|
||||
- **Loop** (`dsh-agent-loop`): `agent/pre-step` fires once per step, after `turn/start` and before `step/start`, awaited; a surface mutation in a `pre-step` listener lands outside the step and is reflected in the single derived request.
|
||||
- **With-key e2e** (`examples/coding-agent`): a real model + real bash session with a lowered `contextWindow`/`retainTokens` triggers compaction mid-session; the test verifies the WORLD (a `compact/start…end` pair landed, the surface shrank, the agent still completed the task after compaction). This is compaction's first real-world exercise and the runaway-survival net.
|
||||
- **Snapshot (deferred, named gap)**: a full-transcript snapshot of a runaway-turn compaction is NOT yet possible — `dsh-llm-replay` derives one model call per `(turn, step)` from `assistant/chunk` events, but the summarization call records no `assistant/chunk`s and carries no `sessionId` (it binds to the anonymous cursor and claims a non-existent extra script). Covering it needs net-new replay infrastructure (record/replay an interleaved summarization call) and is scheduled as a follow-up rather than discovered mid-build.
|
||||
- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn.
|
||||
- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request.
|
||||
- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task.
|
||||
- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work.
|
||||
|
||||
@@ -37,7 +37,7 @@ A new package group `packages/subagent/`:
|
||||
|
||||
### The primitive: async `start → SubagentRun`
|
||||
|
||||
A provider exposes `start(request) → Promise<SubagentRun>`. Promise fulfillment is the publication/readiness and provider-to-caller ownership boundary: for an in-process backend the child is already published in `ctx.agents`, and for ACP the remote session already exists. `SubagentStartRequest.signal` is the single cancellation channel before and after readiness; `SubagentRun` carries the terminal `result` and a `dispose()` method that cancels remaining work and awaits quiescence. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. A rejected start cleans provider-owned partial resources and emits neither subagent lifecycle event.
|
||||
A provider exposes `start(request) → Promise<SubagentRun>`. Fulfillment publishes a ready child and transfers its run handle to the caller. One signal covers cancellation before and after readiness; `dispose()` cancels remaining work and awaits quiescence. A rejected start cleans partial resources and emits no lifecycle event. `start` is transport-neutral; `spawn` names only the fresh in-process backend.
|
||||
|
||||
### Two kinds of optional capability, discovered two ways
|
||||
|
||||
@@ -46,7 +46,7 @@ A provider exposes `start(request) → Promise<SubagentRun>`. Promise fulfillmen
|
||||
|
||||
### Fork vs. fresh are separate backends, not a flag
|
||||
|
||||
Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. The fork backend seeds only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix would give the child an unbalanced turn that the [invariants](../../../../packages/support/invariants/src/index.ts) trace replay rejects.
|
||||
Fresh and forked children are separate providers, not a request flag. `dsh-subagent-spawn` starts an isolated child; `dsh-subagent-fork` seeds a balanced prefix containing only completed parent turns. The in-flight turn is excluded because its subagent call has no result yet and cannot form valid replay history.
|
||||
|
||||
### Child isolation and the parent log
|
||||
|
||||
@@ -54,7 +54,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p
|
||||
|
||||
### Synchronous collect (first cut)
|
||||
|
||||
The `dsh-tool-subagent` consumer passes its execution signal into the start request, awaits the ready run's `result`, and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. A `try/finally` always `dispose()`s the run, so no success, failure, or cancellation path leaks an idle child/session. A non-`completed` stop reason maps to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but intentionally unused in this consumer.
|
||||
`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run in `finally`. Non-completed outcomes become error results rather than successful partial output. This foreground consumer does not use the run's optional steering method.
|
||||
|
||||
### Provider selection is config, not model-facing
|
||||
|
||||
@@ -62,12 +62,11 @@ The `dsh-tool-subagent` consumer passes its execution signal into the start requ
|
||||
|
||||
## Testing
|
||||
|
||||
The seam is tested through the real cordis Loader / export path, not a hand-built `ctx.plugin` mount (which bypasses `unwrapExports` and cannot catch a broken export shape — [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)); the registry pins HMR-safety, duplicate-name rejection, and start-time capability rejection; the nested-agent snapshot scenarios replay keyless in the default gate ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); in-process backends carry real-loop unit tests plus a with-key e2e.
|
||||
The seam is tested through the real Cordis Loader/export path, which catches the export-shape failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Recursion.** Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global `toolFilter`; ACP advertises both capabilities off and rejects such a request. The [subagent composition-controls RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) owns their exact semantics and security limits.
|
||||
- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own).
|
||||
- **Blocking the parent turn.** Foreground collection holds the parent's step open for the child's full duration. Background delegation uses the shared `ctx.tasks` runtime and generic `task_*` tools, the same collection mechanism as background bash; the subagent seam itself remains task-agnostic.
|
||||
- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign.
|
||||
- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process.
|
||||
- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`. It was built single-session: a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and a harness that harvested a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needed per-session-keyed replay plus harvest-all-logs and plural-session-id plumbing — self-contained infrastructure orthogonal to the backends, scheduled as a dedicated stacked follow-up rather than folded into the in-process-backends PR. That follow-up has **landed**: see [Per-session snapshot replay for nested agents](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). Replay now keys each call by its calling session (`GenerateOptions.sessionId`) and binds live sessions to recorded scripts by first-call order; the harness harvests every log; and two nested scenarios (`subagent-spawn`, `subagent-multi`) replay keyless in the default gate. In-process subagents remain covered by real-loop unit tests and a with-key e2e in addition to the snapshot tier.
|
||||
|
||||
@@ -32,17 +32,15 @@ The child is a separate process, so it inherits an environment. Credential-shape
|
||||
|
||||
## Testing
|
||||
|
||||
Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time:
|
||||
|
||||
- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Coverage includes the prompt round-trip and output accumulation; every StopReason mapping; cancellation through the required request signal and through disposal; already-aborted and cancel-races-ahead-of-newSession starts; a torn pipe after cancellation settling `aborted`; permission auto-answer under both policies; non-message updates; nonexistent-command startup failure with process reaping; provider HMR; and the namespace export shape.
|
||||
- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e.
|
||||
- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [the per-session replay RFC](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child.
|
||||
- **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports.
|
||||
- **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file.
|
||||
- **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Why not the 0.28.x SDK bump?
|
||||
### Why stay on SDK 0.25.1?
|
||||
|
||||
The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up.
|
||||
The backend needs only `ClientSideConnection`, `ndJsonStream`, `PROTOCOL_VERSION`, and the client protocol types, all supported in 0.25.1. The 0.28 fluent API would require migrating both client and server connection classes across the ACP layer without improving this backend, so that upgrade remains a separate change.
|
||||
|
||||
### Why not a persistent child process?
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway
|
||||
|
||||
## UI mappings
|
||||
|
||||
`dsh-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time.
|
||||
`dsh-stdio-demo`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time.
|
||||
|
||||
`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
|
||||
|
||||
@@ -46,4 +46,4 @@ The feature gives the model a powerful pause primitive, so prompt guidance matte
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-agent` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
|
||||
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-demo` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
|
||||
|
||||
@@ -32,7 +32,7 @@ claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew i
|
||||
|
||||
### Single owner — no swarm machinery (YAGNI)
|
||||
|
||||
The list belongs to the ONE agent session that called the tool (`exec.agent.session`); a non-agent caller is rejected. There is deliberately no shared/multi-owner scope, no capability seam (interface/impl/consumer), no scope resolver, and no delta protocol. The harness does have subagents, and a shared cross-agent list is conceivable — but building that now means designing for a form the product does not yet have. The whole-list-replace + single-owner shape is what claude-code V1, opencode, and codex all ship; if a shared list is ever needed, the on-log representation would change to per-item deltas (so concurrent writers can't clobber each other) and a scope resolver would choose the target log. That is a future RFC, not speculative scaffolding today.
|
||||
Each list belongs to the calling agent session, and non-agent calls are rejected. There is no shared scope, resolver, or delta protocol. Cross-agent lists would require per-item log deltas and explicit scope selection, so they remain a separate future design.
|
||||
|
||||
### Validation: the cheap middle
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user