Merge remote-tracking branch 'origin/master' into codex/skill-system
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: dsh-pre-push-checks
|
||||
description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes.
|
||||
---
|
||||
|
||||
# DSH Pre-Push Checks
|
||||
|
||||
Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, demo smoke, and built-bin smoke.
|
||||
|
||||
## First Steps
|
||||
|
||||
1. Confirm the checkout and branch.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git rev-parse --show-toplevel
|
||||
```
|
||||
|
||||
2. Inspect the outgoing diff.
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git diff --name-only origin/$(git branch --show-current)...HEAD
|
||||
```
|
||||
|
||||
If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch.
|
||||
|
||||
3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence.
|
||||
|
||||
## Required Baseline
|
||||
|
||||
Run these before every non-trivial push:
|
||||
|
||||
```sh
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run test:coverage
|
||||
```
|
||||
|
||||
Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI.
|
||||
|
||||
## Add Gates By Touched Surface
|
||||
|
||||
Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, RFCs, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages.
|
||||
|
||||
Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`.
|
||||
|
||||
Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures.
|
||||
|
||||
```sh
|
||||
pnpm run test:snapshot
|
||||
```
|
||||
|
||||
Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
|
||||
```
|
||||
|
||||
Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets.
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e
|
||||
```
|
||||
|
||||
Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior.
|
||||
|
||||
## Full Local CI Approximation
|
||||
|
||||
Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior.
|
||||
|
||||
## Handling Failures
|
||||
|
||||
If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs.
|
||||
|
||||
If a failure looks environment-specific, prove it:
|
||||
|
||||
- Record the exact command, failing test, and platform-specific mismatch.
|
||||
- Confirm the relevant non-platform gates pass.
|
||||
- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate.
|
||||
- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI.
|
||||
|
||||
Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass.
|
||||
|
||||
## Push Procedure
|
||||
|
||||
1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented.
|
||||
2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it.
|
||||
3. Push normally first so the pre-push hook can run.
|
||||
4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response.
|
||||
5. After push, verify the remote ref matches local HEAD.
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD origin/$(git branch --show-current)
|
||||
```
|
||||
|
||||
For GitHub PRs, check CI after push:
|
||||
|
||||
```sh
|
||||
gh pr checks
|
||||
```
|
||||
|
||||
If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "DSH Pre-Push Checks"
|
||||
short_description: "Run the right DeepSeek Harness gates before push"
|
||||
default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."
|
||||
+120
-72
@@ -9,14 +9,99 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
node-24:
|
||||
runs-on: ubuntu-latest
|
||||
name: node 24 / ${{ matrix.lane }}
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }}
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }}
|
||||
DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
include:
|
||||
- lane: static
|
||||
command: pnpm run check:ci:static
|
||||
gate_concurrency: '4'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
- lane: lint
|
||||
command: pnpm run check:ci:lint
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: '1'
|
||||
- lane: coverage
|
||||
command: pnpm run check:ci:coverage
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: '4'
|
||||
eslint_cache: ''
|
||||
- lane: snapshot
|
||||
command: pnpm run check:ci:snapshot
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
- lane: artifacts
|
||||
command: pnpm run check:ci:artifacts
|
||||
gate_concurrency: '3'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.lane == 'lint'
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-
|
||||
|
||||
- name: Run gates
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
node-compat:
|
||||
runs-on: ubuntu-latest
|
||||
name: node ${{ matrix.node }}
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: '2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ['22.19', 24, 26]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -27,78 +112,41 @@ jobs:
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Constraints
|
||||
run: pnpm run constraints
|
||||
- name: Run compatibility gates
|
||||
run: pnpm run check:node-compat
|
||||
|
||||
# Before lint: root typecheck validates the package/vendor reference graph
|
||||
# and refreshes TSC intermediates so type-aware ESLint sees the same project
|
||||
# boundaries as the build.
|
||||
- name: Typecheck (src + tests + examples)
|
||||
run: pnpm run typecheck
|
||||
|
||||
# Type-aware ESLint loads every package tsconfig through the project
|
||||
# service and peaks at ~3.4GB; the default V8 old-space ceiling (~2GB)
|
||||
# OOMs it (exit 134). Raise the ceiling well above the peak.
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
# Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the
|
||||
# fenced ts blocks against the root project-reference graph. The cordis
|
||||
# catalog freshness check, type-equiv check, Mermaid syntax check, and
|
||||
# markdown wrap/link checks only read source. Same `doc-sync` script the pre-push hook runs
|
||||
# (quality-gates RFC: one source of truth).
|
||||
- name: Doc-sync gates (doc code blocks + catalogs + mermaid + markdown)
|
||||
run: pnpm run doc-sync
|
||||
|
||||
# Module-graph freshness: regenerate docs/module-graph.md from the
|
||||
# packages' peerDependencies and fail if it differs from the committed
|
||||
# file. Only reads source package.json — no build needed.
|
||||
- name: Module-graph freshness
|
||||
run: pnpm run verify-module-graph
|
||||
|
||||
- name: Tests with coverage gate (per-file 100%)
|
||||
run: pnpm run test:coverage
|
||||
|
||||
# ACP snapshot tests (acp-snapshot-tests RFC): boot the real acp-agent
|
||||
# subprocess and replay recorded session-log fixtures, diffing the
|
||||
# normalized stdout transcript + re-persisted log against committed
|
||||
# goldens. KEYLESS by design — the same `test:snapshot` script the pre-push
|
||||
# hook runs (one source of truth), so the full-transcript regression net
|
||||
# is part of every PR gate, not just local pre-push.
|
||||
- name: Snapshot tests (ACP transcript replay)
|
||||
run: pnpm run test:snapshot
|
||||
|
||||
# Before hygiene: publint validates the packed artifacts (lib/index.js),
|
||||
# which only the tsdown bundling step emits, and verify-node-next-types
|
||||
# validates the built declarations.
|
||||
- name: Build (tsc -b + tsdown bundles)
|
||||
run: pnpm run build
|
||||
|
||||
- name: Hygiene (knip + publint + constraints + NodeNext types)
|
||||
run: pnpm run hygiene
|
||||
|
||||
- name: Demo smoke test
|
||||
# Single stable required check for branch protection: require "all checks
|
||||
# passed" instead of enumerating matrix legs whose names change as lanes and
|
||||
# node versions evolve. Every other job in THIS workflow must be listed in
|
||||
# `needs` (`needs` cannot reach across workflow files; e2e.yml stays its own
|
||||
# check). `if: always()` is load-bearing: without it a failed dependency
|
||||
# would SKIP this job, and GitHub counts a skipped required check as passing
|
||||
# — so this job always runs and fails on any non-success result, including
|
||||
# 'cancelled' and 'skipped'.
|
||||
all-checks-passed:
|
||||
name: all checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [node-24, node-compat]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Fail if any needed job did not succeed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out=$(printf 'echo ci smoke\n' | timeout 60 pnpm run demo:echo 2>&1)
|
||||
echo "$out"
|
||||
echo "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})'
|
||||
echo "$out" | grep -q '\[tool result\] ECHO: CI SMOKE'
|
||||
# The stdio app records process.cwd(); the JSONL backend stores that
|
||||
# session under the cwd bucket as main-session-<uuid>.jsonl.
|
||||
test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)"
|
||||
rm -rf .sessions
|
||||
|
||||
# The published `bin` is `lib/bin.js`, run under plain `node` by a real
|
||||
# consumer — NOT the tsx dev path the demo smoke and demo:* scripts use.
|
||||
# These keyless smokes boot the BUILT bins (this step runs AFTER the build)
|
||||
# in a temp dir that mirrors a real install, catching a regression in the
|
||||
# published artifact that tsx would mask. They self-skip if lib/ is absent,
|
||||
# so the e2e job (which does not build) does not run them.
|
||||
- name: Built-bin smoke test (published lib/bin.js under node)
|
||||
run: pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
|
||||
echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}"
|
||||
exit 1
|
||||
- name: All checks passed
|
||||
run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})"
|
||||
@@ -49,13 +49,14 @@ permissions:
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
name: e2e
|
||||
# Run on every trusted event. Skip untrusted PRs (forks + Dependabot) where
|
||||
# the secret is withheld — they would otherwise hard-fail the preflight.
|
||||
if: >-
|
||||
github.event_name != 'pull_request'
|
||||
|| !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
# Serial files (fileParallelism: false), 120s/test, retry 2. 45m bounds a
|
||||
# wedged run while leaving headroom for retry storms against a slow API.
|
||||
# Bounded file parallelism (DSH_E2E_MAX_WORKERS), 120s/test, retry 2. 45m
|
||||
# still bounds retry storms against a slow API while the happy path fans out.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -67,6 +68,17 @@ jobs:
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-24-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -97,4 +109,5 @@ jobs:
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com
|
||||
DSH_E2E_MAX_WORKERS: 14
|
||||
run: pnpm run test:e2e
|
||||
@@ -5,6 +5,7 @@ lib/
|
||||
*.tsbuildinfo
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
.cache/
|
||||
examples/*/*.jsonl
|
||||
.sessions/
|
||||
examples/*/.sessions/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
This monorepo hosts **DeepSeek Harness SDK**, an SDK for agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing anything under `packages/` — the service map, event surface, loop lifecycle, and extension seams. The documentation standard is [docs/AGENTS.md](docs/AGENTS.md).
|
||||
This is the DeepSeek Harness group's monorepo; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md).
|
||||
|
||||
## Pre-release stance: foundation over blast radius
|
||||
|
||||
@@ -19,11 +19,13 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
|
||||
compact/ compaction seam + basic backend
|
||||
subagent/ subagent seam + spawn/fork/ACP backends + delegation tool
|
||||
todo/ the todo_write tool
|
||||
guard/ loop-hygiene plugins
|
||||
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
|
||||
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
|
||||
session-persistence/ persistence seam + JSONL/SQLite backends
|
||||
ui/ ACP bridge + app-boot glue + the stdio/ACP app bins
|
||||
support/ dev/test infrastructure: invariants, llm-replay, subagent-mock
|
||||
util/ zero-dependency utilities (Branded<B>)
|
||||
ui/ ACP bridge, app-boot glue, stdio/ACP app bins, user-interaction seam, ask-user tool
|
||||
support/ dev/test infrastructure packages
|
||||
util/ zero-dependency utilities
|
||||
examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)
|
||||
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
|
||||
scripts/ repo gates and generators
|
||||
@@ -34,7 +36,7 @@ Per-package map: the group READMEs, indexed from [packages/README.md](packages/R
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
pnpm install # pnpm workspaces, node >= 24
|
||||
pnpm install # pnpm workspaces, node ^22.19 || >=24
|
||||
pnpm run test # vitest unit tests
|
||||
pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src
|
||||
pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
|
||||
@@ -47,12 +49,13 @@ pnpm run hygiene # knip + publint + workspace constraints + NodeNext cons
|
||||
pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json
|
||||
pnpm run demo:echo # mock-model REPL, no key needed
|
||||
pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY)
|
||||
pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key)
|
||||
pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
|
||||
```
|
||||
|
||||
### Run the CI gates locally before marking a PR ready
|
||||
|
||||
From a fresh clone or worktree, `pnpm run build` first — publint and the NodeNext check validate built `lib/`. The CI-equivalent run:
|
||||
During implementation, run the narrowest affected checks; run this full CI-equivalent sequence only when complete and before marking a PR ready. From a fresh clone/worktree, `pnpm run build` first because publint and NodeNext validate built `lib/`:
|
||||
|
||||
```sh
|
||||
set -euo pipefail
|
||||
@@ -69,7 +72,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})'
|
||||
printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE'
|
||||
test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)"
|
||||
rm -rf .sessions
|
||||
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
|
||||
pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts
|
||||
```
|
||||
|
||||
`test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run.
|
||||
@@ -83,21 +86,22 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
|
||||
- Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
|
||||
- ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; builds are for outside consumers only.
|
||||
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
|
||||
- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header ([catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)).
|
||||
- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise; mode semantics are in the [generated events catalog](docs/cordis-catalog/events.md) header.
|
||||
- **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment.
|
||||
- **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
|
||||
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event ([reconstructability RFC](docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
|
||||
- **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md.
|
||||
- **Capability seams are three packages** — interface / implementation / consumer ([capability seams](docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)); don't split preemptively.
|
||||
- **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively.
|
||||
- **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template).
|
||||
- **No hardcoded tunables in plugins**: anything two deployments could want different — timeouts, caps, model names, base URLs — is a defaulted, validated `Config` field, not a literal; a `DEFAULT_*` constant or test-only seam is not configurability. The test: changeable from `cordis.yml`, no code edit. Protocol/wire constants, external-spec values, security invariants stay hardcoded.
|
||||
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string` ([branded IDs](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)).
|
||||
- **Misconfiguration fails loud**: a config value referencing something that does not exist (a `toolOrder` tool name, a plugin path) throws — at load when the check is self-contained, else at the earliest moment the referent exists (for `toolOrder`, every prompt assembly) — never a silent skip.
|
||||
- **Opaque cross-boundary ids are branded** (`Branded<B>` from `dsh-brand`), never bare `string`.
|
||||
- **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement.
|
||||
- **Symmetry is usually more correct**: parallel values get parallel form; asymmetry smells of a missed extraction.
|
||||
- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR ([worked example](docs/rfc/implemented/simplification/2026-06-19-drop-mutable-session-summary.md)).
|
||||
- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/` ([worked example](docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **Testing policy** — tiers, with-key generosity, real-over-mock, world-verification, real-load-path and published-bin guards: [docs/testing.md](docs/testing.md). A transcript/UX-affecting change needs a snapshot test, or a PR note why none applies.
|
||||
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([render-intent RFC](docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md), [cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **Tests document behavior, not golden truth**: a green test pins what the code DOES, not what it SHOULD do. Before preserving a behavior solely for its test, ask whether it is load-bearing; an artifact changes together with its test, with the why in the PR.
|
||||
- **RFCs are proposals, not golden truth**: validate its premise against current code before implementing; friction is evidence of over-reach — amend on the way to `implemented/`.
|
||||
- **Testing policy** — [docs/testing.md](docs/testing.md). Transcript/UX changes need snapshots or a PR note. Snapshot fixtures must replay on macOS/Linux; avoid GNU/BSD-only commands (e.g. `sed -i`); fix fixtures, not normalizers.
|
||||
- **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
|
||||
- **A new capability seam, lifecycle shape, or transcript surface names its coverage at every tier (unit, e2e, snapshot) at plan time** and verifies the harness can express it — a gap is scheduled work, not a mid-build surprise.
|
||||
- **Merge PRs with merge commits** (`gh pr merge --merge`), never squash/rebase. **Never rewrite a pushed branch**; update a child by merging its parent down. **A review fix lands on the PR that introduced the issue, as a separate commit**, then merges down ([stacked-review guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)).
|
||||
- TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)).
|
||||
@@ -109,13 +113,13 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR
|
||||
|
||||
## Type safety and documentation
|
||||
|
||||
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example).
|
||||
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` carries a comment saying why a narrower type is infeasible. Every module has a module-level doc comment; every export (and non-obvious method) has a JSDoc explaining semantics — contracts, disposal, errors — not the name restated; internal helpers only where non-obvious; one-liners when one line suffices. The export half is mechanical: `verify-export-jsdoc` (in `doc-sync`) requires description prose on every package export plus `@param`/`@returns` (and an annotated return) on function-like ones. Heritage-declared members, plugin-protocol slots, and constructors are exempt — their docs' one home is the seam declaration, the framework protocol, and the class doc respectively. Lean toward the stricter lint rule and the extra mechanical gate: encode invariants in checks (`verify-*` scripts), preferring a narrow justified escape hatch over a rule left off globally. Type gymnastics are acceptable inside core packages when they buy plugin-author DX (the `defineTool` schema DSL is the canonical example).
|
||||
|
||||
Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
|
||||
|
||||
## Editing these instructions
|
||||
|
||||
`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. This file is budget-gated (`verify-doc-budgets`): additions displace something or justify a ceiling raise in the PR.
|
||||
`AGENTS.md` is the real file; `CLAUDE.md` is a symlink to it (root, `packages/`, `examples/`). Edit `AGENTS.md`, never the symlink. Keep it self-contained: state each principle inline instead of citing RFCs (they stay discoverable via the RFC index); linking high-level docs — architecture, testing, cookbooks — is fine. This file is budget-gated (`verify-doc-budgets`): condense first if it is possible without sacrificing clarity; truly needed additions may justify a ceiling raise.
|
||||
|
||||
## Vendoring policy
|
||||
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@ Every fact has exactly one home — the tier whose job it is — and every other
|
||||
| [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 |
|
||||
| [development.md](development.md) | Human-facing setup and daily workflow; a bilingual pair under the [i18n contract](i18n/README.md) | 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/tools.md), [persistence-catalog](persistence-catalog/log-events.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind |
|
||||
| [development.md](development.md) | First-stop contributor onboarding: local setup, daily workflow, and CI shape at summary level; a bilingual pair under the [i18n contract](i18n/README.md) | Runtime/version rationale (→ RFCs), gate-by-gate enumerations that drift from `package.json` scripts |
|
||||
| Generated catalogs: [cordis events](cordis-catalog/events.md), [cordis services](cordis-catalog/services.md), [tool-catalog](tool-catalog.md), [config-catalog](config-catalog.md), [persistence-catalog](persistence-catalog.md), [module-graph.md](module-graph.md) | Exhaustive enumerations regenerated from source, freshness-gated | Hand edits of any kind |
|
||||
| Skills (`.agents/skills/`) | Workflows: how to carry out a recurring task against the contracts | The contracts themselves (→ docs) |
|
||||
|
||||
Placement test: 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.
|
||||
@@ -37,7 +37,7 @@ Placement test: a story about a bug → postmortem. Why we chose X → RFC. How
|
||||
Standing docs accrete: every PR has a lesson it wants to append, and without displacement pressure nothing ever leaves. The gate is that pressure. [scripts/doc-budgets.manifest.json](../scripts/doc-budgets.manifest.json) lists the accretion-prone standing docs with a word ceiling each; `pnpm run verify-doc-budgets` (part of `doc-sync`, so CI and pre-push run it) fails when a doc exceeds its ceiling, and fails when a budgeted file is missing so a rename cannot orphan its budget.
|
||||
|
||||
- Ceilings are an enforcement frontier with working headroom: a ceiling sits at least 5% above the doc's current size — routine edits pass, real growth trips the gate — and ratchets down, keeping the margin, as the doc reaches target. Target budgets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file (which carries the standard) ≤ 1,250; `packages/README.md` ≤ 600.
|
||||
- When the gate goes red, the fix is to relocate or condense per the taxonomy above. Raising a ceiling is the last resort: the PR must justify it; the manifest diff is the reviewable act.
|
||||
- When the gate goes red, first ask whether the added words belong in this tier and whether the existing wording can be condensed. If the words do not belong, relocate per the taxonomy above; if they belong but can be shorter, condense. If they truly need the space, raise the ceiling and justify the manifest diff in the PR. A ceiling set too low is a budget bug, and correcting it is the fix.
|
||||
- Unbudgeted tiers (package READMEs, RFCs, reference matrices) have no ceiling — length is legitimate there when every row is a fact. Review and the slop checklist govern them instead.
|
||||
|
||||
## The slop checklist
|
||||
|
||||
+14
-10
@@ -1,14 +1,14 @@
|
||||
# DeepSeek Harness Architecture
|
||||
|
||||
The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The governing principle is simple: **everything is a plugin**. The shipped agent loop is just one plugin in the default bundle, not a privileged kernel.
|
||||
The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is simple: **everything is a plugin**. The shipped loop is one plugin, not a privileged kernel.
|
||||
|
||||
Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). If Cordis itself is new to you, start with the [Cordis primer](cordis-primer.md).
|
||||
Read this page as the system map before changing `packages/`. It explains how the runtime is shaped, how the default loop moves work, where state lives, and where extensions attach. Type shapes live in [core-data-structures/](core-data-structures/core.md); exact event and service signatures live in the generated [events](cordis-catalog/events.md) and [services](cordis-catalog/services.md) catalogs; package contracts live in the [package map](../packages/README.md); rationale lives in the [RFCs](rfc/README.md). New to Cordis? Start with the [Cordis primer](cordis-primer.md).
|
||||
|
||||
## System Shape
|
||||
|
||||
A running harness is one Cordis context. Packages contribute service keys, typed events, and disposable registrations to that context. Services are the stable call surfaces (`ctx.llm`, `ctx.tools`, `ctx.sessions`); events are interception and notification points (`agent/request`, `tools/pre-execute`, `session/event`); registrations install prompt sections, tool schemas, providers, adapters, and listeners.
|
||||
|
||||
The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins from a Cordis perspective.
|
||||
The default distribution is a composition, not a hierarchy. `packages/core/` is a repository grouping for the default agent spine; capability seams around it are equally first-class plugins.
|
||||
|
||||
### Default Service Spine
|
||||
|
||||
@@ -27,6 +27,7 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is
|
||||
|---|---|---|
|
||||
| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls |
|
||||
| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
|
||||
| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
|
||||
| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
|
||||
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
|
||||
| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction |
|
||||
@@ -39,7 +40,7 @@ Events are the harness extension API. Each service owns the vocabulary for the b
|
||||
|
||||
### Event Domains
|
||||
|
||||
Use the event domain to decide where new behavior belongs:
|
||||
Pick the event domain for new behavior:
|
||||
|
||||
- **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`.
|
||||
- **Agent events** are live runtime surfaces. They carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy.
|
||||
@@ -47,11 +48,11 @@ Use the event domain to decide where new behavior belongs:
|
||||
|
||||
### Interception Semantics
|
||||
|
||||
Waterfall events behave like around-middleware: a listener delegates by calling `next()` and vetoes or takes over by returning without it. The full rule lives in [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics).
|
||||
Waterfall events behave like around-middleware: a listener delegates by calling `next()`; returning without it vetoes or takes over. Full rule: [Cordis waterfall semantics](cordis-primer.md#cordis-waterfall-semantics).
|
||||
|
||||
## Default Loop Lifecycle
|
||||
|
||||
The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. Its architecture is where it pauses: each pause is a documented service call or event seam another plugin can program against.
|
||||
The shipped loop drains queued work, assembles a request, streams a model answer, executes tools, decides whether to continue, and checkpoints durable state. The important architecture is where it pauses: each pause is a documented service call or event seam other plugins program against.
|
||||
|
||||
A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension seams.
|
||||
|
||||
@@ -70,6 +71,7 @@ forever:
|
||||
STEP loop:
|
||||
drain steering
|
||||
assemble system prompt and tool schemas
|
||||
agent/session-prefix (first step)
|
||||
agent/pre-step
|
||||
'step/start'
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
@@ -79,7 +81,7 @@ forever:
|
||||
'assistant/message'
|
||||
each tool call:
|
||||
'tool/call'
|
||||
tools/pre-execute -> dispatch -> tools/post-execute
|
||||
tools/pre-execute -> tools/execute -> tools/post-execute
|
||||
'tool/result'
|
||||
append post-tool context and steering
|
||||
'step/end'
|
||||
@@ -89,7 +91,7 @@ forever:
|
||||
checkpoint persistence and notify idle/running status
|
||||
```
|
||||
|
||||
Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), 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 persona (order 0, from its `persona` config, shared by every agent) — while the shipped 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).
|
||||
Prompt assembly is single-path: `renderPrompt(assemble({ agent }))` IS the system prompt sent to the model. Plugins contribute ordered sections (static or computed from the per-call `AssembleContext`), 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 persona (order 0, its `persona` config, shared context-wide) — while the shipped 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).
|
||||
|
||||
Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; leftover steering after a turn is re-queued as ordinary input.
|
||||
|
||||
@@ -109,7 +111,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
|
||||
|
||||
The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream.
|
||||
|
||||
**Model-visible ⟺ logged**: the log reconstructs every conversation request byte-for-byte — messages by derivation at the `step/start` boundary, the header (system prompt, tools, model + sampling) by folding `request/header` events — asserted per request by the dev invariant ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, headers by folding `request/header` — and dev invariants assert this ([reconstructability RFC](rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
|
||||
Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite.
|
||||
|
||||
@@ -123,7 +125,7 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs
|
||||
|
||||
### Capability Pattern
|
||||
|
||||
A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families.
|
||||
A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the package families.
|
||||
|
||||
Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Skills and subagents use named provider registries; local skills scan project/user roots, and other providers can add embedded or remote catalogs without registry/tool changes. Subagents spawn fresh, fork from the parent's completed-turn prefix, or use ACP children ([subagent.md](core-data-structures/subagent.md)).
|
||||
|
||||
@@ -142,7 +144,9 @@ New behavior should attach to a documented seam; changing the shipped loop requi
|
||||
| Add command execution | implement and register a `ctx.bash` backend |
|
||||
| Add filesystem access or policy | implement a `ctx.fs` provider or listen on `fs/*` policy events |
|
||||
| Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall |
|
||||
| Add a session-stable request prefix outside history | compose it on `agent/session-prefix`, once per loop instance; logged on the request header |
|
||||
| Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
|
||||
| Add durable session state | add a `SessionEventMap` member and render/replay from the log |
|
||||
| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` |
|
||||
|
||||
The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
|
||||
@@ -30,22 +30,29 @@ flowchart LR
|
||||
pkg_tool_fs["tool-fs"]
|
||||
pkg_tool_web["tool-web"]
|
||||
svc_tools["ctx.tools<br/>Tool registry and execution waterfall"]
|
||||
pkg_tool_ask_user["tool-ask-user"]
|
||||
pkg_tool_bash["tool-bash"]
|
||||
pkg_tool_cordis["tool-cordis"]
|
||||
pkg_tool_skill["tool-skill"]
|
||||
pkg_tool_subagent["tool-subagent"]
|
||||
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_skill["skill"]
|
||||
svc_skills["ctx.skills<br/>Skill provider registry"]
|
||||
pkg_agent_core["agent-core"]
|
||||
pkg_skill_local["skill-local"]
|
||||
svc_agents["ctx.agents<br/>Agent registry"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
|
||||
pkg_bash["bash"]
|
||||
svc_bash["ctx.bash<br/>Bash executor seam"]
|
||||
pkg_bash_local["bash-local"]
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
svc_codeRuntime["ctx.codeRuntime<br/>Code-execution seam"]
|
||||
pkg_code_runtime_worker["code-runtime-worker"]
|
||||
pkg_fs["fs"]
|
||||
svc_fs["ctx.fs<br/>Filesystem provider seam"]
|
||||
pkg_fs_local["fs-local"]
|
||||
@@ -64,10 +71,13 @@ flowchart LR
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
pkg_web_search_deepseek["web-search-deepseek"]
|
||||
pkg_web_fetch_local["web-fetch-local"]
|
||||
pkg_acp --> svc_userInteraction
|
||||
pkg_agent --> svc_agents
|
||||
pkg_agent_loop --> svc_agentLoop
|
||||
pkg_bash --> svc_bash
|
||||
pkg_bash_local --> svc_bash
|
||||
pkg_code_runtime --> svc_codeRuntime
|
||||
pkg_code_runtime_worker --> svc_codeRuntime
|
||||
pkg_compact --> svc_compact
|
||||
pkg_compact_basic --> svc_compact
|
||||
pkg_fs --> svc_fs
|
||||
@@ -81,6 +91,7 @@ flowchart LR
|
||||
pkg_session_persistence_jsonl --> svc_sessionPersistence
|
||||
pkg_session_persistence_sqlite --> svc_sessionPersistence
|
||||
pkg_skill --> svc_skills
|
||||
pkg_stdio_agent --> svc_userInteraction
|
||||
pkg_subagent --> svc_subagents
|
||||
pkg_subagent_acp --> svc_subagents
|
||||
pkg_subagent_fork --> svc_subagents
|
||||
@@ -88,6 +99,7 @@ flowchart LR
|
||||
pkg_subagent_spawn --> svc_subagents
|
||||
pkg_system_prompt --> svc_systemPrompt
|
||||
pkg_tools --> svc_tools
|
||||
pkg_user_interaction --> svc_userInteraction
|
||||
pkg_web --> svc_web
|
||||
pkg_web_fetch_local --> svc_web
|
||||
pkg_web_search_deepseek --> svc_web
|
||||
@@ -123,12 +135,17 @@ flowchart LR
|
||||
svc_systemPrompt --> pkg_tools
|
||||
svc_tools --> pkg_acp
|
||||
svc_tools --> pkg_agent_loop
|
||||
svc_tools --> pkg_tool_ask_user
|
||||
svc_tools --> pkg_tool_bash
|
||||
svc_tools --> pkg_tool_cordis
|
||||
svc_tools --> pkg_tool_fs
|
||||
svc_tools --> pkg_tool_skill
|
||||
svc_tools --> pkg_tool_subagent
|
||||
svc_tools --> pkg_tool_todo
|
||||
svc_tools --> pkg_tool_web
|
||||
svc_userInteraction --> pkg_acp
|
||||
svc_userInteraction --> pkg_stdio_agent
|
||||
svc_userInteraction --> pkg_tool_ask_user
|
||||
svc_web --> pkg_tool_web
|
||||
svc_fs -. event gate .-> pkg_fs_policy
|
||||
```
|
||||
@@ -139,11 +156,13 @@ flowchart LR
|
||||
| `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.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-bash`](../packages/bash/tool-bash), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/core/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 tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
|
||||
| `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/core/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 tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
|
||||
| `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.skills` | `core` | [`skill`](../packages/core/skill) | - | [`agent-core`](../packages/core/agent-core), [`skill-local`](../packages/core/skill-local), [`tool-skill`](../packages/core/tool-skill) | - | Merges provider skill catalogs, injects request-time listings, and serves full skill bodies to the skill tool. |
|
||||
| `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.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`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 can replace bash-local. |
|
||||
| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | - | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the Code Mode RFC specifies the worker-thread backend and the tool-registry consumer). |
|
||||
| `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. |
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
<!-- Generated by scripts/gen-config-catalog.ts — do not edit by hand.
|
||||
Run `pnpm run gen-config-catalog` to regenerate. -->
|
||||
|
||||
# Plugin Config Catalog
|
||||
|
||||
Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin's full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.
|
||||
|
||||
This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.
|
||||
|
||||
A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.
|
||||
|
||||
## `@deepseek-ai/dsh-acp`
|
||||
|
||||
Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
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`.
|
||||
*/
|
||||
stream?: Stream
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: `Stream` (`@agentclientprotocol/sdk`)
|
||||
|
||||
Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-acp-agent`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* App config: the swappable per-deployment values. `model` configures the
|
||||
* agent template the ACP bridge creates each session's agent from (NOT a
|
||||
* pre-created agent — ACP creates agents at `session/new`); `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);
|
||||
* `persistenceRoot` is the JSONL backend's directory.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||
model: string
|
||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry/local-provider config forwarded to the shared agent-core spine. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts)
|
||||
|
||||
Source: [`packages/ui/acp-agent/src/index.ts:50`](../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), and `skills` to the skill registry/local provider. Every field is
|
||||
* optional INPUT here because each owner's schema supplies the default (`[]` /
|
||||
* `''` / absent — lexicographic / the DSH skill roots); the schema is the
|
||||
* INTERSECTION of the owners' own schemas, 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']
|
||||
/** Skill registry and local provider config. */
|
||||
skills?: SkillConfig
|
||||
}
|
||||
|
||||
/** Skill bundle config forwarded to the registry and the local provider. */
|
||||
export interface SkillConfig {
|
||||
/** Registry-level prompt/cache settings. */
|
||||
registry?: SkillRegistryConfig
|
||||
/** Local filesystem skill provider settings. */
|
||||
local?: SkillLocal.Config
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/core/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt)
|
||||
|
||||
Source: [`packages/core/agent-core/src/index.ts:84`](../packages/core/agent-core/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
|
||||
* declaratively at startup, so a cordis.yml deployment needs no code.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
agents: (AgentOptions & {
|
||||
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
|
||||
id: AgentId
|
||||
/** Optional workspace cwd for the config-created fresh session. */
|
||||
cwd?: string
|
||||
/**
|
||||
* If set, the config agent RESUMES this persisted session id instead of
|
||||
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
|
||||
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
|
||||
* demo can continue a prior conversation without code changes. Requires a
|
||||
* `dsh-session-persistence` backend; the resume is deferred until that
|
||||
* service is available (via `ctx.inject`) and the loaded session's events
|
||||
* seed the live session so history continues.
|
||||
*
|
||||
* The schema accepts a plain string at runtime (cordis.yml values are
|
||||
* untyped); the brand is compile-time only — the config format is the
|
||||
* boundary where an id enters, so the TYPE declares the brand here.
|
||||
*/
|
||||
resumeSessionId?: SessionId
|
||||
})[]
|
||||
}
|
||||
```
|
||||
|
||||
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:36`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
|
||||
graceMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/bash-local/src/index.ts:29`](../packages/bash/bash-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-code-runtime-worker`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
|
||||
* once the worker's MEASURED event-loop active time
|
||||
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
|
||||
* measured busy time — not wall time, not host-side pending-call
|
||||
* bookkeeping — is what makes the budget both fair (a program awaiting a
|
||||
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
|
||||
* or not a decoy dispatch is in flight).
|
||||
*/
|
||||
computeMs?: number
|
||||
/**
|
||||
* Wall-clock ceiling in milliseconds; never pauses for anything. The
|
||||
* backstop for what busy-time cannot see (a program awaiting a promise
|
||||
* nobody will resolve).
|
||||
*/
|
||||
maxWallMs?: number
|
||||
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
|
||||
maxLogBytes?: number
|
||||
/**
|
||||
* Byte cap for the completion value, measured by its real cross-boundary
|
||||
* size (string bytes, or structured-clone wire size); an oversized or
|
||||
* non-cloneable value crosses as a capped string rendering.
|
||||
*/
|
||||
maxValueBytes?: number
|
||||
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:29`](../packages/code-runtime/code-runtime-worker/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-compact-basic`
|
||||
|
||||
Requires: `llm`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
contextWindow: number
|
||||
/** Compact when estimated token usage exceeds this fraction of context window. */
|
||||
thresholdRatio: number
|
||||
/** Number of tokens of recent context to retain during compaction. */
|
||||
retainTokens: number
|
||||
/** Model to use for summarization (`''` — uses the agent's model). */
|
||||
summarizationModel: string
|
||||
/** Provider generation cap for the summarization call. */
|
||||
maxTokens: number
|
||||
/** Extra compaction attempts when the first compacted surface is still over threshold. */
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/compact/compact-basic/src/types.ts:20`](../packages/compact/compact-basic/src/types.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the local filesystem backend. */
|
||||
export interface Config {
|
||||
/** Base directory for relative paths. Defaults to `process.cwd()`. */
|
||||
cwd?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/fs-local/src/index.ts:58`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-hooks-claude`
|
||||
|
||||
Requires: `bash`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: where the CC hook config lives + substitution roots. */
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
configPath: string
|
||||
/**
|
||||
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
|
||||
*/
|
||||
pluginRoot?: string
|
||||
/**
|
||||
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
|
||||
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
|
||||
* defaults per-run to the agent's session workspace (`session.header.cwd`, the
|
||||
* same dir the hook runs in) — Claude Code always exports this var, and common
|
||||
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
|
||||
*/
|
||||
projectDir?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hooks-claude/src/index.ts:56`](../packages/hooks/hooks-claude/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-hooks-codex`
|
||||
|
||||
Requires: `bash`
|
||||
|
||||
```ts config-catalog
|
||||
/** 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 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.
|
||||
*/
|
||||
configPath: string
|
||||
/** The model name stamped on every payload (Codex includes `model` on each event). */
|
||||
model?: string
|
||||
/** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */
|
||||
defaultTimeoutMs?: number
|
||||
/** Character cap for the `hook/result` event's persisted stderr summary. */
|
||||
stderrSummaryMaxChars?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-invariants`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Deep-freeze logged session-event data so mutating a logged event throws.
|
||||
* Default true — this plugin only runs in dev/test, where freezing is the
|
||||
* point. Set false to assert the event contract without freezing.
|
||||
*/
|
||||
freeze?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/invariants/src/index.ts:45`](../packages/support/invariants/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-deepseek`
|
||||
|
||||
Requires: `llm`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call), and omitted
|
||||
* thinking fields send nothing on the wire, so the provider default applies.
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/** Thinking-mode default for every request (provider default: enabled). */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-deepseek/src/index.ts:43`](../packages/llm/llm-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-pi-ai`
|
||||
|
||||
Requires: `llm`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
* missing API key fails plugin load, not the first call).
|
||||
*/
|
||||
export interface Config {
|
||||
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Model names to register (sent verbatim on the wire). */
|
||||
models?: string[]
|
||||
/**
|
||||
* Thinking level for every request: 'off' disables thinking mode; 'high'
|
||||
* and 'xhigh' (wire 'max') set the effort. Omitted = provider default
|
||||
* (thinking enabled), matching llm-deepseek's omission semantics.
|
||||
*/
|
||||
reasoning?: PiAiReasoning
|
||||
}
|
||||
|
||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||
export type PiAiReasoning = 'off' | 'high' | 'xhigh'
|
||||
```
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/index.ts:37`](../packages/llm/llm-pi-ai/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-replay`
|
||||
|
||||
Requires: `llm`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the {@link ReplayConfig} inputs, each defaulting to its `DSH_SNAPSHOT_*` env var in `apply`. */
|
||||
export interface Config {
|
||||
/** Override the fixture path; defaults to `$DSH_SNAPSHOT_FILE`. */
|
||||
file?: string
|
||||
/** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */
|
||||
overrideFile?: string
|
||||
/**
|
||||
* Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a
|
||||
* path-separator-delimited list). Each is a recorded subagent session log for
|
||||
* a nested-agent scenario; absent/empty for a single-session scenario.
|
||||
*/
|
||||
childFiles?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:429`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-repeat-tool-guard`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema plus the
|
||||
* load-time checks in `apply` (misconfiguration fails loud: an empty
|
||||
* `thresholds` list, a non-integer, a value below 2, or a duplicate throws at
|
||||
* plugin load, never a silent fall-back). `include`/`exclude` entries are
|
||||
* `*`-wildcard predicates over tool names at call time, not references to
|
||||
* registry entries — a pattern matching no currently registered tool is valid
|
||||
* (`exclude: [mcp_*]` must stay legal in a deployment that loads no MCP tools).
|
||||
*/
|
||||
export interface Config {
|
||||
/** Consecutive-repeat counts that trigger a reminder (default `[3, 5, 8]`). */
|
||||
thresholds?: number[]
|
||||
/** Tool-name patterns to track; empty means every tool is tracked. */
|
||||
include?: string[]
|
||||
/** Tool-name patterns transparent to the chain (neither count nor reset). */
|
||||
exclude?: string[]
|
||||
/**
|
||||
* Maximum characters of canonical arguments quoted in the DETAILED reminder
|
||||
* (default 500). Large payloads (a `write` body, a long command) would
|
||||
* otherwise ride into the next request unbounded — precisely in a loop
|
||||
* scenario; the cap bounds the reminder, never the detection (the chain key
|
||||
* always compares the FULL canonical string).
|
||||
*/
|
||||
argumentsPreviewChars?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-jsonl`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
|
||||
export interface Config {
|
||||
/**
|
||||
* Root directory for all session files. Required (no default): a default of
|
||||
* `process.cwd()` would scatter session files as the process's cwd changes
|
||||
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
|
||||
*/
|
||||
root: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:35`](../packages/session-persistence/session-persistence-jsonl/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-sqlite`
|
||||
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Filesystem path to the SQLite database file. The special value `:memory:`
|
||||
* opens an in-process database (tests); a file path is created (with parent
|
||||
* dirs) on construction.
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* SQLite `journal_mode` pragma. `wal` (the default) is the recorded
|
||||
* durability model; pick a rollback-journal mode (`delete`/`truncate`/
|
||||
* `persist`) on filesystems where WAL's shared-memory files do not work
|
||||
* (network mounts). See {@link JournalMode}.
|
||||
*/
|
||||
journalMode?: JournalMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal modes the backend will run under. `wal` is the default and the
|
||||
* durability model the persistence ADR records; the rollback-journal modes
|
||||
* (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
|
||||
* shared-memory files do not work (network mounts). `memory`/`off` are
|
||||
* excluded: dropping journal durability silently contradicts what this
|
||||
* backend promises.
|
||||
*/
|
||||
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)
|
||||
|
||||
## `@deepseek-ai/dsh-skill`
|
||||
|
||||
```ts config-catalog
|
||||
/** Skill registry configuration. */
|
||||
export interface Config {
|
||||
/** Maximum rendered description/whenToUse length in the prompt listing; minimum 3. */
|
||||
promptFieldMaxLength?: number
|
||||
/** Maximum number of cwd/provider discovery promises kept in the in-memory cache. */
|
||||
collectCacheMaxEntries?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/skill/src/index.ts:111`](../packages/core/skill/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-skill-local`
|
||||
|
||||
Requires: `skills`
|
||||
|
||||
```ts config-catalog
|
||||
/** Local filesystem skill provider configuration. */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
||||
agentsHome?: string
|
||||
/** Additional skill roots scanned after project roots and before user roots. */
|
||||
customSkillDirs?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/skill-local/src/index.ts:39`](../packages/core/skill-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-stdio-agent`
|
||||
|
||||
```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
|
||||
* 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
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for the `main` agent (must have a registered adapter). */
|
||||
model: string
|
||||
/** Deployment persona (the system-prompt plugin's `persona` config). */
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** 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 config forwarded to the shared agent-core spine. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
* (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`).
|
||||
*/
|
||||
resumeSessionId?: string
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts)
|
||||
|
||||
Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-acp`
|
||||
|
||||
Requires: `subagents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config: how to spawn and drive the child ACP agent process. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `acp`). */
|
||||
providerName: string
|
||||
/** The executable to spawn for each run (the child ACP agent). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args: string[]
|
||||
/**
|
||||
* Working directory for the child process and its ACP session. Defaults to
|
||||
* the parent process's cwd when omitted.
|
||||
*/
|
||||
cwd?: string
|
||||
/**
|
||||
* How to auto-answer the child's `session/request_permission` prompts:
|
||||
* `reject` (default — decline every prompt) or `allow` (approve via the first
|
||||
* allow-shaped option). The first cut surfaces no prompt to a human.
|
||||
*/
|
||||
permission: PermissionPolicy
|
||||
/**
|
||||
* Extra environment variables for the child process — e.g. the child
|
||||
* harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed
|
||||
* copy of the parent env, so an explicit key here reaches the child while
|
||||
* ambient secrets do not leak implicitly.
|
||||
*/
|
||||
env: Record<string, string>
|
||||
/**
|
||||
* Grace period (ms) for the child's EOF-driven quiesce on dispose — its
|
||||
* window to flush persistence and tear down its own nested subprocesses
|
||||
* before the parent escalates to a signal.
|
||||
*/
|
||||
disposeEofGraceMs?: number
|
||||
/** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */
|
||||
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.
|
||||
*/
|
||||
export type PermissionPolicy = 'allow' | 'reject'
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-acp/src/index.ts:30`](../packages/subagent/subagent-acp/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-fork`
|
||||
|
||||
Requires: `subagents` · `agents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `fork`). */
|
||||
providerName: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-fork/src/index.ts:38`](../packages/subagent/subagent-fork/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-mock`
|
||||
|
||||
Requires: `subagents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config for the mock provider; all optional with test-friendly defaults. */
|
||||
export interface Config {
|
||||
/** Registry name to register under. */
|
||||
name: string
|
||||
/** The text the scripted child "returns" as its final answer. */
|
||||
reply?: string
|
||||
/** The stop reason the run settles with. */
|
||||
stopReason?: SubagentStopReason
|
||||
/** Which start-time capabilities to advertise (default: all `true`). */
|
||||
capabilities?: Partial<SubagentCapabilities>
|
||||
/**
|
||||
* The context contract to declare ({@link SubagentProvider.inheritsParentContext});
|
||||
* default `false` (spawn-like). Set `true` to exercise the fork-shaped tool
|
||||
* wording in consumer tests.
|
||||
*/
|
||||
inheritsParentContext?: boolean
|
||||
/**
|
||||
* Structured value surfaced when a request carries an `outputSchema` and the
|
||||
* `outputSchema` capability is on (default: `{ reply }`).
|
||||
*/
|
||||
structured?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
Source: [`packages/support/subagent-mock/src/index.ts:84`](../packages/support/subagent-mock/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-spawn`
|
||||
|
||||
Requires: `subagents` · `agents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config: the registry name to register the provider under. */
|
||||
export interface Config {
|
||||
/** Provider name on `ctx.subagents` (default `spawn`). */
|
||||
providerName: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent-spawn/src/index.ts:36`](../packages/subagent/subagent-spawn/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** 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, subagents included. 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
toolOrder?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:179`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-cordis`
|
||||
|
||||
Requires: `tools`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config for the tool-cordis plugin: the sandbox evaluation bound. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Milliseconds the SYNCHRONOUS portion of mount code may run in the vm
|
||||
* before evaluation is aborted (default 5000). An async body escapes this
|
||||
* bound — see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md for the trust stance.
|
||||
*/
|
||||
vmTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts:53`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
Requires: `tools` · `fs` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default and maximum number of lines returned by one `read` call. */
|
||||
readLimit?: number
|
||||
/** Maximum characters returned for a single line before truncation. */
|
||||
readMaxLineLength?: number
|
||||
/** Maximum bytes returned for the selected lines of one `read` call. */
|
||||
readMaxBytes?: number
|
||||
/** Files at or above this size stream instead of loading whole into memory. */
|
||||
readStreamMinSize?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts:48`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-subagent`
|
||||
|
||||
Requires: `tools` · `subagents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Config: which registered provider this tool delegates to, plus child defaults. */
|
||||
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' }`.
|
||||
*/
|
||||
toolName?: string
|
||||
/**
|
||||
* Default per-child agent options (model) applied to every spawned child.
|
||||
* Omitted fields fall back to the child loop's own defaults. There is no
|
||||
* per-child persona: the deployment persona (the system-prompt plugin's
|
||||
* `persona` config) is a context-wide section every agent shares.
|
||||
*/
|
||||
agentOptions?: AgentOptions
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts)
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts:44`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
Requires: `tools` · `web` · `systemPrompt`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
|
||||
export interface Config {
|
||||
/** Register `web_search`. Defaults to true. */
|
||||
search?: boolean
|
||||
/** Register `web_fetch`. Defaults to true. */
|
||||
fetch?: boolean
|
||||
/** Upper bound on sources returned by one `web_search` call. */
|
||||
searchMaxResults?: number
|
||||
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
searchTimeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/tool-web/src/index.ts:40`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Config for the web seam. `searchProvider` / `fetchProvider` pin which provider
|
||||
* wins for each capability; both are optional (a single registered usable
|
||||
* provider auto-selects). Operational overrides such as environment variables
|
||||
* must feed these same fields rather than introduce a hidden priority chain.
|
||||
*/
|
||||
export interface WebServiceConfig {
|
||||
/** Explicit search provider id. Omitted = auto-select when exactly one usable. */
|
||||
readonly searchProvider?: string
|
||||
/** Explicit fetch provider id. Omitted = auto-select when exactly one usable. */
|
||||
readonly fetchProvider?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts:68`](../packages/web/web/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-fetch-local`
|
||||
|
||||
Requires: `web`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
|
||||
export interface Config {
|
||||
/** Maximum accepted request URL length. */
|
||||
maxUrlLength?: number
|
||||
/** Maximum response body size in bytes. */
|
||||
maxResponseBytes?: number
|
||||
/** Maximum decoded body length in characters. */
|
||||
maxBodyChars?: number
|
||||
/** Default fetch timeout in milliseconds. */
|
||||
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. */
|
||||
userAgent?: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-deepseek`
|
||||
|
||||
Requires: `web`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
/** Anthropic-compatible endpoint base; `/messages` is appended. */
|
||||
baseURL?: string
|
||||
/** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
|
||||
model?: string
|
||||
/** `anthropic-version` header value. Defaults to `2023-06-01`. */
|
||||
apiVersion?: string
|
||||
/** Upper bound on generated tokens for the Messages request. Defaults to 4096. */
|
||||
maxTokens?: number
|
||||
/** Maximum `web_search` server-tool uses per request. Defaults to 5. */
|
||||
maxUses?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-deepseek/src/index.ts:48`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-exa`
|
||||
|
||||
Requires: `web`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/search` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */
|
||||
searchType?: 'auto' | 'keyword' | 'neural'
|
||||
/** Default result count when a request carries no `maxResults`. Omitted = none. */
|
||||
numResults?: number
|
||||
/** Highlight sentences requested per result. Defaults to 1. */
|
||||
highlightsPerResult?: number
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-exa/src/index.ts:39`](../packages/web/web-search-exa/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-web-search-perplexity`
|
||||
|
||||
Requires: `web`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
export interface Config {
|
||||
/** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
|
||||
apiKey?: string
|
||||
/** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */
|
||||
baseURL?: string
|
||||
/** Search model name. Defaults to `sonar`. */
|
||||
model?: string
|
||||
/** Upper bound on generated answer tokens. Defaults to 1024. */
|
||||
maxTokens?: number
|
||||
/** Recency window sent as `search_recency_filter`. Omitted = no filter. */
|
||||
searchRecency?: 'day' | 'week' | 'month' | 'year'
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/web-search-perplexity/src/index.ts:33`](../packages/web/web-search-perplexity/src/index.ts)
|
||||
|
||||
## Loadable plugins with no config
|
||||
|
||||
These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.
|
||||
|
||||
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
|
||||
- `@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-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-skill` — requires `tools` · `skills` ([`packages/core/tool-skill/src/index.ts`](../packages/core/tool-skill/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-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
|
||||
## Seam packages (not directly loadable)
|
||||
|
||||
Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
|
||||
- `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts))
|
||||
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
|
||||
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
|
||||
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts))
|
||||
|
||||
## Library packages (no plugin entry)
|
||||
|
||||
Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
|
||||
- `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts))
|
||||
- `@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-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
@@ -23,7 +23,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/disposed` — emit
|
||||
|
||||
@@ -35,7 +35,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/error` — emit
|
||||
|
||||
@@ -47,21 +47,21 @@ 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:404`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:476`](../../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). `signal` cancels any in-flight work a listener starts (e.g. a summarization model call).
|
||||
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).
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:357`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -73,7 +73,7 @@ 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:346`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:370`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/queued` — emit
|
||||
|
||||
@@ -85,11 +85,11 @@ A message entered the agent's inbox (queued or steering). `source` is 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:273`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:290`](../../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` — 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.
|
||||
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.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
@@ -97,7 +97,23 @@ 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:369`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:394`](../../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.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
```
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:441`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -109,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source`
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -121,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:264`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/step-result` — waterfall
|
||||
|
||||
@@ -133,7 +149,7 @@ 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:379`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:451`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
@@ -145,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:392`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:464`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
@@ -243,7 +259,7 @@ A skill provider became resolvable in the `ctx.skills` registry. Consumers can o
|
||||
'skill/provider-added'(provider: SkillProvider): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/skill/src/index.ts:127`](../../packages/core/skill/src/index.ts)
|
||||
Source: [`packages/core/skill/src/index.ts:131`](../../packages/core/skill/src/index.ts)
|
||||
|
||||
### `skill/provider-removed` — emit
|
||||
|
||||
@@ -253,7 +269,7 @@ A skill provider left the registry because its plugin fiber was disposed.
|
||||
'skill/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/skill/src/index.ts:133`](../../packages/core/skill/src/index.ts)
|
||||
Source: [`packages/core/skill/src/index.ts:137`](../../packages/core/skill/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
@@ -329,11 +345,23 @@ A tool was registered or unregistered (the available tool set changed).
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:118`](../../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 mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed arguments and re-invokes downstream with the shared payload, so a wrapper mutates `exec` 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.
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:97`](../../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. The core tool dispatch sits between the two waterfalls as plain code, 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).
|
||||
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).
|
||||
|
||||
```ts cordis-catalog
|
||||
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
@@ -341,7 +369,7 @@ 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:82`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
### `tools/pre-execute` — waterfall
|
||||
|
||||
@@ -353,7 +381,7 @@ 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:66`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:77`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## Inherited events (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ createAgent(options: CreateAgentOptions): AgentHandle
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:65`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:70`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
@@ -67,6 +67,25 @@ Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../c
|
||||
|
||||
Source: [`packages/bash/bash/src/index.ts:59`](../../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()`).
|
||||
|
||||
```ts cordis-catalog
|
||||
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)
|
||||
|
||||
## `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).
|
||||
@@ -79,11 +98,13 @@ Implementations MUST honor:
|
||||
- **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.
|
||||
|
||||
```ts cordis-catalog
|
||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||
```
|
||||
|
||||
Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts)
|
||||
Types: [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/index.ts:65`](../../packages/compact/compact/src/index.ts)
|
||||
|
||||
## `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
@@ -124,7 +145,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:84`](../../packages/llm/llm/src/index.ts)
|
||||
Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts)
|
||||
|
||||
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
|
||||
|
||||
@@ -146,7 +167,7 @@ abstract list(): Promise<SessionHeader[]>
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:98`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessions` — `SessionStore`
|
||||
|
||||
@@ -161,9 +182,10 @@ enter(session: Session): () => void
|
||||
announce(session: Session): void
|
||||
get(id: SessionId): Session | undefined
|
||||
list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:371`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:405`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
@@ -177,7 +199,7 @@ async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefiniti
|
||||
async renderModelListing(options: SkillLookupOptions = {}): Promise<string>
|
||||
```
|
||||
|
||||
Source: [`packages/core/skill/src/index.ts:154`](../../packages/core/skill/src/index.ts)
|
||||
Source: [`packages/core/skill/src/index.ts:158`](../../packages/core/skill/src/index.ts)
|
||||
|
||||
## `ctx.subagents` — `SubagentService`
|
||||
|
||||
@@ -200,14 +222,14 @@ Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections,
|
||||
section(section: PromptSection): () => void
|
||||
tools(provider: () => ToolSchema[]): () => void
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
|
||||
assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Source: [`packages/core/system-prompt/src/index.ts:198`](../../packages/core/system-prompt/src/index.ts)
|
||||
Source: [`packages/core/system-prompt/src/index.ts:291`](../../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
## `ctx.tools` — `ToolRegistry`
|
||||
|
||||
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly.
|
||||
Tool registry (`ctx.tools`): tool plugins register definitions; the agent loop executes calls through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline. The registry contributes its schemas into the system-prompt assembly.
|
||||
|
||||
```ts cordis-catalog
|
||||
register(definition: ToolDefinition): () => void
|
||||
@@ -218,7 +240,18 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:307`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## `ctx.userInteraction` — `UserInteractionService`
|
||||
|
||||
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
|
||||
|
||||
```ts cordis-catalog
|
||||
registerProvider(provider: UserInteractionProvider): () => void
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
```
|
||||
|
||||
Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts)
|
||||
|
||||
## `ctx.web` — `WebService`
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Code Runtime
|
||||
|
||||
The code-execution seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) whose interface ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread backend and the tool-registry consumer (Code Mode) are specified in the [Code Mode RFC](../rfc/proposed/feature/2026-06-15-code-mode.md).
|
||||
|
||||
Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts)
|
||||
|
||||
## The run: request in, result out
|
||||
|
||||
A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`:
|
||||
|
||||
```ts type-equiv
|
||||
interface CodeRunRequest {
|
||||
/**
|
||||
* The program source, in the runtime's {@link ../index.ts | language}. It
|
||||
* runs as the body of an async function: top-level `await` and `return`
|
||||
* are available, and the completion value becomes
|
||||
* {@link CodeRunResult.value}.
|
||||
*/
|
||||
program: string
|
||||
/** Host functions exposed to the program, one global object per namespace. */
|
||||
bindings: CodeBindingNamespace[]
|
||||
/**
|
||||
* Abort the run: the runtime stops the program (hard, even mid-loop) and
|
||||
* resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight
|
||||
* binding calls are the CALLER's to settle — the runtime only stops asking.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract):
|
||||
|
||||
```ts type-equiv
|
||||
interface CodeRunResult {
|
||||
/**
|
||||
* The program's completion value (its top-level `return`), when it ran to
|
||||
* completion and the value survived the runtime's serialization boundary;
|
||||
* a non-transferable value is replaced by a string rendering, and a failed
|
||||
* or value-less run leaves this absent.
|
||||
*/
|
||||
value?: unknown
|
||||
/** Everything the program emitted, in order (capped by the implementation). */
|
||||
logs: CodeLogEntry[]
|
||||
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
|
||||
error?: CodeRunFailure
|
||||
}
|
||||
```
|
||||
|
||||
## Bindings: host functions as program globals
|
||||
|
||||
Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision):
|
||||
|
||||
```ts type-equiv
|
||||
interface CodeBindingNamespace {
|
||||
/** The global identifier the program sees (must be a valid JS identifier). */
|
||||
global: string
|
||||
/** The callable members, keyed by the exact name the program calls. */
|
||||
functions: Record<string, CodeBindingFunction>
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither:
|
||||
|
||||
```ts type-equiv
|
||||
interface CodeRunFailure {
|
||||
/** The failure class (see the interface doc for each kind's meaning). */
|
||||
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'
|
||||
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
|
||||
message: string
|
||||
}
|
||||
```
|
||||
|
||||
## The service
|
||||
|
||||
`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` is the well-known value; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes.
|
||||
@@ -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, 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`, 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` (`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.
|
||||
|
||||
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.
|
||||
@@ -19,7 +19,9 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
|
||||
| [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` |
|
||||
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
|
||||
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
|
||||
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
|
||||
| [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`, prompt listing, model-facing `skill` loading |
|
||||
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
|
||||
@@ -128,6 +130,12 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
|
||||
```ts type-equiv
|
||||
interface GenerateOptions {
|
||||
model: string
|
||||
/**
|
||||
* Ordered conversation messages, exactly as the provider sees them (after
|
||||
* the `system` slot). A loop-built request assembles them as
|
||||
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
|
||||
* hand-built one-shot passes any list.
|
||||
*/
|
||||
messages: Message[]
|
||||
/** System prompt text (adapters map to the provider's system slot). */
|
||||
system?: string
|
||||
@@ -188,7 +196,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-content half of a request — the `EpochHeader`: this call configuration plus the rendered system prompt and assembled tool schemas — 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 loop logs whatever the request actually uses. Loop-built requests arrive at `llm/stream` deep-frozen; mutation throws.
|
||||
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 assembly's canonical order (dsh-system-prompt's `toolOrder` config, lexicographic 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.
|
||||
|
||||
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.
|
||||
|
||||
FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.
|
||||
|
||||
@@ -321,7 +331,7 @@ interface Agent {
|
||||
}
|
||||
```
|
||||
|
||||
`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, and the `agent/prompt-submit`/`agent/request`/`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'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging; the persona is NOT an agent option but the `dsh-system-prompt` plugin's `persona` config, shared context-wide. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, the serial `agent/pre-step` surface-mutation seam, 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.
|
||||
|
||||
## Interception decisions
|
||||
|
||||
@@ -358,6 +368,8 @@ type ContinuationDecision =
|
||||
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.
|
||||
|
||||
## `ToolDefinition`
|
||||
|
||||
The one pipeline-authoring type that is core: what every registered tool *is* — a model-facing `ToolSchema` plus an `execute` function and optional UI presenters. A tool author rarely constructs it by hand (the `defineTool` DSL builds it with typed args), but it is the contract the registry holds and the loop dispatches through.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Session Persistence
|
||||
|
||||
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog/log-events.md).
|
||||
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md).
|
||||
|
||||
The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
|
||||
|
||||
## `SessionEventMap` — the event vocabulary
|
||||
|
||||
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog/log-events.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
|
||||
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
|
||||
|
||||
```ts type-equiv
|
||||
interface SessionEventMap {
|
||||
@@ -74,16 +74,15 @@ interface SessionEventMap {
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Amendment to the folded {@link EpochHeader}: at least one of a
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing). 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 {@link SurfaceEventType}.
|
||||
* Amendment to the folded {@link EpochHeader}: system line-trim, name-keyed
|
||||
* tools delta, whole replacement config, or whole replacement session
|
||||
* prefix (an EMPTY array encodes the transition to "none"). 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
|
||||
* {@link SurfaceEventType}.
|
||||
*/
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -100,7 +99,7 @@ export interface TodoItem {
|
||||
|
||||
### The request header events: `request/header` and `request/header-delta`
|
||||
|
||||
The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message.
|
||||
The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A `request/header` snapshot (reason `'initial' | 'resume' | 'fallback'`) anchors the fold at conversation birth, process boundaries, and delta-encoding fallbacks; `request/header-delta` events amend it mid-run. `foldRequestHeader(events)` reconstructs the header any request was built under; the writer round-trip-verifies every delta before logging it, so a well-formed log always folds. Neither is a `SurfaceEventType` — they produce no LLM message.
|
||||
|
||||
```ts type-equiv
|
||||
export interface EpochHeader {
|
||||
@@ -110,10 +109,18 @@ export interface EpochHeader {
|
||||
system?: string
|
||||
/** Assembled tool schemas; absent for a tool-less request. */
|
||||
tools?: ToolSchema[]
|
||||
/**
|
||||
* The session prefix: request-only messages sent BEFORE the entire derived
|
||||
* history (the `agent/session-prefix` waterfall's product, composed once
|
||||
* per loop instance and reused for every request it sends). Not session
|
||||
* history — `deriveMessages()` never returns it — so the header is its
|
||||
* only durable record; absent when the instance composed none.
|
||||
*/
|
||||
messagePrefix?: Message[]
|
||||
}
|
||||
```
|
||||
|
||||
Canonical form: an empty system prompt and an empty tool list are ABSENT fields, matching how requests are built. The delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts).
|
||||
Canonical form: an empty system prompt, an empty tool list, and an empty session prefix are ABSENT fields, matching how requests are built. `messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product (the request is `messagePrefix + derived history`); composed once per loop instance and anchored by that instance's snapshot, so the loop never produces a prefix delta in practice — the delta arm (whole-array replacement, an empty array encoding the transition back to absence) exists for codec totality. The other delta payloads (`SystemDelta` — a common-prefix/suffix line trim; `ToolsDelta` — name-keyed added/removed/changed) live beside the events in [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts).
|
||||
|
||||
## `SessionEvent<T>` — one log entry
|
||||
|
||||
@@ -200,6 +207,14 @@ export interface SurfaceNode {
|
||||
|
||||
Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`.
|
||||
|
||||
## Live-session fork API
|
||||
|
||||
`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API:
|
||||
|
||||
- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`).
|
||||
|
||||
An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit.
|
||||
|
||||
## What started a turn: `TurnTriggerMap`
|
||||
|
||||
```ts type-equiv
|
||||
@@ -264,7 +279,7 @@ Every session event lives **inside** a turn (between a `turn/start` and its `tur
|
||||
|
||||
## Plugin-contributed log-only events
|
||||
|
||||
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog/log-events.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
|
||||
A plugin may declaration-merge extra `SessionEventMap` types. These are **log-only**: NOT `SurfaceEventType`s (they carry no `surfaceOp` and contribute nothing to derived history), but, like every event, they must sit inside an open turn. The full per-event enumeration — core and plugin-contributed alike, with payloads and provenance — is the generated [persistence log event catalog](../persistence-catalog.md); the compaction seam's `compact/*` semantics are discussed on [compaction.md](compaction.md).
|
||||
|
||||
The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepseek-ai/dsh-hook-protocol`) correlate by `handlerId`. The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see [the hook-bridges RFC](../rfc/implemented/feature/2026-06-30-hook-bridges.md)).
|
||||
|
||||
|
||||
@@ -20,7 +20,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 three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag.
|
||||
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 three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. `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)).
|
||||
|
||||
```ts type-equiv
|
||||
interface SubagentStartRequest {
|
||||
@@ -28,7 +28,7 @@ interface SubagentStartRequest {
|
||||
parent: Agent
|
||||
signal?: AbortSignal
|
||||
agentOptions?: AgentOptions
|
||||
outputSchema?: SchemaSpec
|
||||
outputSchema?: StructuredOutputSchema
|
||||
maxDepth?: number
|
||||
toolFilter?: { allow?: string[]; deny?: string[] }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona
|
||||
```ts type-equiv
|
||||
interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
|
||||
/**
|
||||
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
|
||||
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
|
||||
* is NEVER sent to the model — `schemas()` whitelists only name/description/
|
||||
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
|
||||
* cooperative implementation that can reach quiescence when the signal aborts.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived from
|
||||
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
|
||||
@@ -138,6 +146,40 @@ type PostToolDecision =
|
||||
|
||||
Call `next()` to delegate to the default (allow / accept-unchanged), or return a decision to short-circuit. A `pre-execute` `deny` (or `ask`, which degrades to deny until the permission system lands) skips dispatch and yields an `isError` result; input rewrite is deliberately NOT offered on `PreToolDecision` (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC). A `post-execute` `accept` may replace the model-facing `content` (clean, because `tool/result` is logged after `execute()` returns); a `block` turns the call into an `isError` whose content is the corrective `feedback`. Core dispatch sits between the waterfalls as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. 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.
|
||||
|
||||
## The structured-output schema subset
|
||||
|
||||
The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily.
|
||||
|
||||
```ts type-equiv
|
||||
type StructuredScalar = string | number | boolean | null
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface StructuredSchemaNode {
|
||||
type: StructuredSchemaType
|
||||
properties?: Record<string, StructuredSchemaNode>
|
||||
required?: string[]
|
||||
additionalProperties?: boolean
|
||||
items?: StructuredSchemaNode
|
||||
enum?: StructuredScalar[]
|
||||
const?: StructuredScalar
|
||||
description?: string
|
||||
title?: string
|
||||
default?: unknown
|
||||
examples?: unknown
|
||||
}
|
||||
```
|
||||
|
||||
A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire):
|
||||
|
||||
```ts type-equiv
|
||||
type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
|
||||
```
|
||||
|
||||
## Tool-presentation UI vocabulary
|
||||
|
||||
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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.
|
||||
|
||||
Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts)
|
||||
|
||||
## Question options
|
||||
|
||||
`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionOption {
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Optional extra context rendered by capable UIs. */
|
||||
description?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Question item
|
||||
|
||||
`AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionItem {
|
||||
/** Stable model-provided question id, echoed in the answer. */
|
||||
id: string
|
||||
/** The question to display. */
|
||||
question: string
|
||||
/** Optional short heading/group label. */
|
||||
header?: string
|
||||
/** Optional choices the UI can render as a menu. */
|
||||
options?: AskUserQuestionOption[]
|
||||
/** Whether more than one option may be selected. Defaults to single-select. */
|
||||
multiSelect?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Ask request
|
||||
|
||||
`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionRequest {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Calling agent, when the request came from an agent tool call. */
|
||||
agent?: Agent
|
||||
/** Abort signal for the owning tool/step. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
```
|
||||
|
||||
## Answer
|
||||
|
||||
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionAnswerItem {
|
||||
/** The answered question id. */
|
||||
id: string
|
||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
||||
selected: string[]
|
||||
/** Optional free-text "Other" answer. */
|
||||
custom?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
interface AskUserQuestionAnswer {
|
||||
/** Structured answers keyed by question id. */
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
}
|
||||
```
|
||||
|
||||
## Provider
|
||||
|
||||
Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI.
|
||||
|
||||
```ts type-equiv
|
||||
interface UserInteractionProvider {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
|
||||
|
||||
```ts type-equiv
|
||||
class UserInteractionError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'UserInteractionError'
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has
|
||||
|
||||
## The service
|
||||
|
||||
`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), 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` (`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.
|
||||
@@ -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: f032764fff29baaca007211db8b69d9a5129078f
|
||||
development.zh.md: 3a650d03ce7cafd0e34290ae918e5a303c2ad8a9
|
||||
development.md: bd6f6b561480419abea7a42a44b4078e2c59b1cb
|
||||
development.zh.md: 54bf19765d2b4dc419e6b71684dbcfcd28230541
|
||||
+7
-19
@@ -2,11 +2,11 @@
|
||||
|
||||
English | [中文](development.zh.md)
|
||||
|
||||
This guide covers the local setup needed to work on DeepSeek Harness and understand the local hooks, daily checks, and CI gates.
|
||||
This onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the RFCs for design rationale and technical trade-offs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24 or newer. The repo declares `node >=24`; CI runs the matrix on Node 24 and 26.
|
||||
- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md).
|
||||
- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
|
||||
- Git.
|
||||
- Optional: a DeepSeek API key for the REPL/ACP agent demos and real-API e2e tests.
|
||||
@@ -59,30 +59,17 @@ 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 test`, `pnpm run test:snapshot`, `pnpm run hygiene`, `pnpm run doc-sync`, and `pnpm run verify-module-graph`.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the matrix on Node 24 and 26.
|
||||
These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs echo-agent and built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26.
|
||||
|
||||
## CI gates
|
||||
|
||||
The GitHub workflow runs these gates on each pull request:
|
||||
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 lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- `pnpm run constraints`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run lint`
|
||||
- `pnpm run doc-sync`
|
||||
- `pnpm run verify-module-graph`
|
||||
- `pnpm run test:coverage`
|
||||
- `pnpm run test:snapshot`
|
||||
- `pnpm run build`
|
||||
- `pnpm run hygiene`
|
||||
- an echo-agent smoke test that checks the demo's tool call, tool result, and JSONL output
|
||||
- built-bin smoke tests that run the published `lib/bin.js` entrypoints under plain `node`
|
||||
|
||||
`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`.
|
||||
`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`.
|
||||
|
||||
## Daily commands
|
||||
|
||||
@@ -98,6 +85,7 @@ pnpm run lint:fix # eslint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
|
||||
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
|
||||
pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc
|
||||
pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions
|
||||
pnpm run verify-doc-graphs # fail if generated relationship docs are stale
|
||||
pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree
|
||||
|
||||
+7
-19
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](development.md) | 中文
|
||||
|
||||
本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建,并帮助你理解本地钩子、日常检查与 CI 门禁。
|
||||
本文面向参与项目开发的贡献者,帮助你上手本地环境、日常工作流和 CI 流程。相关设计考量和技术取舍参见 RFC,不在这里展开。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Node.js 24 或更新版本。仓库声明 `node >=24`;CI 在 Node 24 和 26 上跑矩阵。
|
||||
- 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`。
|
||||
- Git。
|
||||
- 可选:一个 DeepSeek API key,用于 REPL/ACP agent(智能体)演示和真实 API 的 e2e 测试。
|
||||
@@ -59,30 +59,17 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。
|
||||
- `pre-push` 运行 `pnpm run test`、`pnpm run test:snapshot`、`pnpm run hygiene`、`pnpm run doc-sync` 和 `pnpm run verify-module-graph`。
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行单元测试、快照测试、build、module graph 新鲜度,以及 `pnpm run hygiene` 和 `pnpm run doc-sync` 的成员门禁。
|
||||
|
||||
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 24 和 26 上跑矩阵。
|
||||
这些钩子并不与 CI 完全一致。特别是:`pre-push` 跑不带覆盖率的单元测试,而 CI 跑 `pnpm run test:coverage`;CI 还会跑 echo-agent 和 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上跑兼容性矩阵。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
GitHub 工作流在每个 pull request 上运行这些门禁:
|
||||
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`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- `pnpm run constraints`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run lint`
|
||||
- `pnpm run doc-sync`
|
||||
- `pnpm run verify-module-graph`
|
||||
- `pnpm run test:coverage`
|
||||
- `pnpm run test:snapshot`
|
||||
- `pnpm run build`
|
||||
- `pnpm run hygiene`
|
||||
- 一个 echo-agent 冒烟测试,检查演示的工具调用、工具结果和 JSONL 输出
|
||||
- built-bin 冒烟测试,用纯 `node` 运行发布产物 `lib/bin.js` 入口
|
||||
|
||||
`pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。
|
||||
`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。
|
||||
|
||||
## 日常命令
|
||||
|
||||
@@ -98,6 +85,7 @@ pnpm run lint:fix # eslint . --fix
|
||||
pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs
|
||||
pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source
|
||||
pnpm run verify-cordis-catalog # fail if either cordis catalog is stale
|
||||
pnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc
|
||||
pnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions
|
||||
pnpm run verify-doc-graphs # fail if generated relationship docs are stale
|
||||
pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the RFC tree
|
||||
|
||||
@@ -7,17 +7,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:404`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:288`](../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:264`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:392`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:272`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:476`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:357`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:370`](../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), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:290`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:394`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:441`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:305`](../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:281`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/ui/acp), [`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:451`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:464`](../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) |
|
||||
| `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) |
|
||||
@@ -25,16 +26,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `skill/provider-added` | `emit` | [`packages/core/skill/src/index.ts:127`](../packages/core/skill/src/index.ts) | [`skill`](../packages/core/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/core/skill/src/index.ts:133`](../packages/core/skill/src/index.ts) | [`skill`](../packages/core/skill) (`emit`) | - |
|
||||
| `skill/provider-added` | `emit` | [`packages/core/skill/src/index.ts:131`](../packages/core/skill/src/index.ts) | [`skill`](../packages/core/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/core/skill/src/index.ts:137`](../packages/core/skill/src/index.ts) | [`skill`](../packages/core/skill) (`emit`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../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:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../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:38`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`skill`](../packages/core/skill) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:44`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:87`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:66`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:118`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:97`](../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:113`](../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:77`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
|
||||
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`.
|
||||
+3
-2
@@ -3,17 +3,18 @@
|
||||
|
||||
# Documentation Graph Index
|
||||
|
||||
These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog/](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md).
|
||||
These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the generated [events](cordis-catalog/events.md) / [services](cordis-catalog/services.md) catalogs, [tool-catalog.md](tool-catalog.md), and [core-data-structures/](core-data-structures/core.md).
|
||||
|
||||
The process decision behind this index is recorded in [the documentation graph RFC](rfc/implemented/process/2026-07-03-documentation-graph-atlas.md).
|
||||
|
||||
| Graph | Mode |
|
||||
| --- | --- |
|
||||
| [module dependency graph](module-graph.md) | `generated` |
|
||||
| [tool schema catalog and package map](tool-catalog/tools.md) | `generated` |
|
||||
| [tool schema catalog and package map](tool-catalog.md) | `generated` |
|
||||
| [capability seams and core services](capability-seams.md) | `hybrid generated` |
|
||||
| [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` |
|
||||
| [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` |
|
||||
| [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` |
|
||||
| [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` |
|
||||
| [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` |
|
||||
| [agent turn and step lifecycle](agent-lifecycle.md) | `curated` |
|
||||
|
||||
+52
-6
@@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
|
||||
flowchart TD
|
||||
subgraph group_util["packages/util"]
|
||||
pkg_brand["brand"]
|
||||
pkg_timeout["timeout"]
|
||||
end
|
||||
subgraph group_llm["packages/llm"]
|
||||
pkg_llm["llm"]
|
||||
@@ -57,9 +58,15 @@ flowchart TD
|
||||
pkg_web_search_exa["web-search-exa"]
|
||||
pkg_web_search_perplexity["web-search-perplexity"]
|
||||
end
|
||||
subgraph group_timeout["packages/timeout"]
|
||||
pkg_timeout_policy["timeout-policy"]
|
||||
end
|
||||
subgraph group_todo["packages/todo"]
|
||||
pkg_tool_todo["tool-todo"]
|
||||
end
|
||||
subgraph group_cordis["packages/cordis"]
|
||||
pkg_tool_cordis["tool-cordis"]
|
||||
end
|
||||
subgraph group_hooks["packages/hooks"]
|
||||
pkg_hook_protocol["hook-protocol"]
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
@@ -71,6 +78,7 @@ flowchart TD
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
end
|
||||
subgraph group_support["packages/support"]
|
||||
pkg_acp_snapshot["acp-snapshot"]
|
||||
pkg_invariants["invariants"]
|
||||
pkg_llm_replay["llm-replay"]
|
||||
pkg_subagent_mock["subagent-mock"]
|
||||
@@ -80,15 +88,26 @@ flowchart TD
|
||||
pkg_acp_agent["acp-agent"]
|
||||
pkg_app_boot["app-boot"]
|
||||
pkg_stdio_agent["stdio-agent"]
|
||||
pkg_tool_ask_user["tool-ask-user"]
|
||||
pkg_user_interaction["user-interaction"]
|
||||
end
|
||||
subgraph group_code_runtime["packages/code-runtime"]
|
||||
pkg_code_runtime["code-runtime"]
|
||||
pkg_code_runtime_worker["code-runtime-worker"]
|
||||
end
|
||||
subgraph group_guard["packages/guard"]
|
||||
pkg_repeat_tool_guard["repeat-tool-guard"]
|
||||
end
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_bash --> pkg_brand
|
||||
pkg_code_runtime_worker --> pkg_code_runtime
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
pkg_llm_pi_ai --> pkg_llm
|
||||
pkg_session --> pkg_brand
|
||||
pkg_session --> pkg_llm
|
||||
pkg_system_prompt --> pkg_llm
|
||||
pkg_bash_local --> pkg_bash
|
||||
pkg_bash_local --> pkg_timeout
|
||||
pkg_fs --> pkg_brand
|
||||
pkg_fs --> pkg_llm
|
||||
pkg_web --> pkg_llm
|
||||
@@ -100,6 +119,7 @@ flowchart TD
|
||||
pkg_fs_policy --> pkg_fs
|
||||
pkg_compact --> pkg_llm
|
||||
pkg_compact --> pkg_session
|
||||
pkg_web_fetch_local --> pkg_timeout
|
||||
pkg_web_fetch_local --> pkg_web
|
||||
pkg_web_search_deepseek --> pkg_web
|
||||
pkg_web_search_exa --> pkg_web
|
||||
@@ -125,6 +145,8 @@ flowchart TD
|
||||
pkg_invariants --> pkg_agent
|
||||
pkg_invariants --> pkg_llm
|
||||
pkg_invariants --> pkg_session
|
||||
pkg_user_interaction --> pkg_agent
|
||||
pkg_user_interaction --> pkg_llm
|
||||
pkg_agent_loop --> pkg_agent
|
||||
pkg_agent_loop --> pkg_llm
|
||||
pkg_agent_loop --> pkg_session
|
||||
@@ -154,9 +176,13 @@ flowchart TD
|
||||
pkg_tool_web --> pkg_system_prompt
|
||||
pkg_tool_web --> pkg_tools
|
||||
pkg_tool_web --> pkg_web
|
||||
pkg_timeout_policy --> pkg_llm
|
||||
pkg_timeout_policy --> pkg_timeout
|
||||
pkg_timeout_policy --> pkg_tools
|
||||
pkg_tool_todo --> pkg_agent
|
||||
pkg_tool_todo --> pkg_session
|
||||
pkg_tool_todo --> pkg_tools
|
||||
pkg_tool_cordis --> pkg_tools
|
||||
pkg_hooks_codex --> pkg_agent
|
||||
pkg_hooks_codex --> pkg_hook_protocol
|
||||
pkg_hooks_codex --> pkg_llm
|
||||
@@ -167,6 +193,12 @@ flowchart TD
|
||||
pkg_acp --> pkg_session
|
||||
pkg_acp --> pkg_session_persistence
|
||||
pkg_acp --> pkg_tools
|
||||
pkg_acp --> pkg_user_interaction
|
||||
pkg_tool_ask_user --> pkg_agent
|
||||
pkg_tool_ask_user --> pkg_tools
|
||||
pkg_tool_ask_user --> pkg_user_interaction
|
||||
pkg_repeat_tool_guard --> pkg_agent
|
||||
pkg_repeat_tool_guard --> pkg_tools
|
||||
pkg_agent_core --> pkg_agent
|
||||
pkg_agent_core --> pkg_agent_loop
|
||||
pkg_agent_core --> pkg_invariants
|
||||
@@ -185,6 +217,8 @@ flowchart TD
|
||||
pkg_subagent_inprocess --> pkg_llm
|
||||
pkg_subagent_inprocess --> pkg_session
|
||||
pkg_subagent_inprocess --> pkg_subagent
|
||||
pkg_subagent_inprocess --> pkg_system_prompt
|
||||
pkg_subagent_inprocess --> pkg_tools
|
||||
pkg_tool_subagent --> pkg_agent
|
||||
pkg_tool_subagent --> pkg_llm
|
||||
pkg_tool_subagent --> pkg_subagent
|
||||
@@ -208,32 +242,39 @@ flowchart TD
|
||||
pkg_acp_agent --> pkg_agent_core
|
||||
pkg_acp_agent --> pkg_app_boot
|
||||
pkg_acp_agent --> pkg_session_persistence_jsonl
|
||||
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_session
|
||||
pkg_stdio_agent --> pkg_session_persistence_jsonl
|
||||
pkg_stdio_agent --> pkg_tool_ask_user
|
||||
pkg_stdio_agent --> pkg_user_interaction
|
||||
```
|
||||
|
||||
| Package | Group | Depends on |
|
||||
| --- | --- | --- |
|
||||
| [`brand`](../packages/util/brand) | `util` | — |
|
||||
| [`timeout`](../packages/util/timeout) | `util` | — |
|
||||
| [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — |
|
||||
| [`app-boot`](../packages/ui/app-boot) | `ui` | — |
|
||||
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) |
|
||||
| [`bash`](../packages/bash/bash) | `bash` | [`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) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash) |
|
||||
| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) |
|
||||
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
|
||||
| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`web`](../packages/web/web) |
|
||||
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) |
|
||||
@@ -246,6 +287,7 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
|
||||
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`skill-local`](../packages/core/skill-local) | `core` | [`fs`](../packages/fs/fs), [`skill`](../packages/core/skill) |
|
||||
| [`tool-skill`](../packages/core/tool-skill) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/core/skill), [`tools`](../packages/core/tools) |
|
||||
@@ -253,16 +295,20 @@ flowchart TD
|
||||
| [`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) |
|
||||
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) |
|
||||
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
|
||||
| [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) |
|
||||
| [`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) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`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) |
|
||||
| [`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/core/skill), [`skill-local`](../packages/core/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/core/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-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
| [`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) |
|
||||
| [`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), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) |
|
||||
| [`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), [`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), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
# Persistence Log Event Catalog
|
||||
|
||||
Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](../core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](../core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](../cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
|
||||
Every event type that can appear in a session's durable event log: each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with the payload it carries, its surface badge, and the declaration it comes from. It complements [session.md](core-data-structures/session.md) (the `SessionEvent` envelope, surface list, and `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).
|
||||
|
||||
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](../rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
|
||||
This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Payload blocks use a `ts persistence-catalog` fence (skipped by doc-typecheck, since a bare payload fragment is not standalone-compilable). Type names in a payload link to the page that documents them. See [the persistence-log-catalog RFC](rfc/implemented/process/2026-07-04-persistence-log-catalog.md).
|
||||
|
||||
The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](../core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](../core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
|
||||
The on-disk envelope around every payload is `SessionEvent` — `type`, monotonic `seq`, epoch-ms `time`, the `data` documented here, plus `surfaceOp`/`sourceEventSeqs` on **surface** events only ([envelope](core-data-structures/session.md#sessioneventt--one-log-entry)). **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
|
||||
|
||||
## Events
|
||||
|
||||
@@ -21,9 +21,9 @@ Raw stream chunk — token-level replay fidelity.
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
```
|
||||
|
||||
Types: [StreamChunk](../core-data-structures/llm-streaming.md)
|
||||
Types: [StreamChunk](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:298`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `assistant/message` — surface
|
||||
|
||||
@@ -33,9 +33,9 @@ Assembled assistant message for one step (derived history uses this). Carries th
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md) · [TokenUsage](../core-data-structures/llm-streaming.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:305`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `compact/*`
|
||||
|
||||
@@ -47,7 +47,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:46`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
#### `compact/start` — log-only
|
||||
|
||||
@@ -57,7 +57,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:23`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
#### `compact/summary` — log-only
|
||||
|
||||
@@ -67,9 +67,9 @@ Provenance record of a completed summarization — log-only, no surfaceOp. The s
|
||||
'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number; model: string; maxTokens?: number }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md)
|
||||
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:30`](../packages/compact/compact/src/types.ts)
|
||||
|
||||
### `context/*`
|
||||
|
||||
@@ -81,9 +81,9 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte
|
||||
'context/message': { content: ContentBlock[]; source: MessageSource }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:296`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `hook/*`
|
||||
|
||||
@@ -95,7 +95,7 @@ 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:27`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
|
||||
#### `hook/result` — log-only
|
||||
|
||||
@@ -105,7 +105,7 @@ A hook command's outcome — log-only, paired with a prior `hook/invoked` (same
|
||||
'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:45`](../packages/hooks/hook-protocol/src/types.ts)
|
||||
|
||||
### `prompt/*`
|
||||
|
||||
@@ -117,9 +117,9 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:290`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:305`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `request/*`
|
||||
|
||||
@@ -131,17 +131,17 @@ Full snapshot of the EpochHeader the NEXT request is built under, with the Reque
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:350`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:365`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `request/header-delta` — log-only
|
||||
|
||||
Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, or a whole replacement LlmCallConfig (four scalars — not worth diffing). 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.
|
||||
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.
|
||||
|
||||
```ts persistence-catalog
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:361`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `steering/*`
|
||||
|
||||
@@ -153,9 +153,9 @@ Steering content injected between steps of a running turn.
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:323`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `step/*`
|
||||
|
||||
@@ -167,7 +167,7 @@ Closes step `step` of turn `turn`.
|
||||
'step/end': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:277`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `step/start` — log-only
|
||||
|
||||
@@ -177,7 +177,7 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it
|
||||
'step/start': { turn: number; step: number }
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:275`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:290`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `todo/*`
|
||||
|
||||
@@ -191,9 +191,9 @@ NOT a SurfaceEventType: it produces no LLM message and never reaches `deriveMess
|
||||
'todo/write': { todos: TodoItem[] }
|
||||
```
|
||||
|
||||
Types: [TodoItem](../core-data-structures/session.md)
|
||||
Types: [TodoItem](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:337`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `tool/*`
|
||||
|
||||
@@ -205,9 +205,9 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
```
|
||||
|
||||
Types: [CallId](../core-data-structures/core.md)
|
||||
Types: [CallId](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:311`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `tool/result` — surface
|
||||
|
||||
@@ -217,9 +217,9 @@ A completed tool call's model-facing result, plus an optional tool-private `meta
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
|
||||
```
|
||||
|
||||
Types: [CallId](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md)
|
||||
Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:321`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `turn/*`
|
||||
|
||||
@@ -231,9 +231,9 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
```
|
||||
|
||||
Types: [TurnEndReason](../core-data-structures/session.md)
|
||||
Types: [TurnEndReason](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:273`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts)
|
||||
|
||||
#### `turn/start` — log-only
|
||||
|
||||
@@ -243,9 +243,9 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
```
|
||||
|
||||
Types: [TurnTrigger](../core-data-structures/session.md)
|
||||
Types: [TurnTrigger](core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:267`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts)
|
||||
|
||||
### `user/*`
|
||||
|
||||
@@ -257,6 +257,6 @@ A user-visible prompt (queued message drained at turn start).
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
```
|
||||
|
||||
Types: [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
|
||||
Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/types.ts:279`](../../packages/core/session/src/types.ts)
|
||||
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
|
||||
+17
-1
@@ -10,8 +10,9 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
|---|---|
|
||||
| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 |
|
||||
| [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 |
|
||||
| [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 |
|
||||
| [Code Mode — the model writes TypeScript against the tool registry](proposed/feature/2026-06-15-code-mode.md) | 2026-06-15 |
|
||||
| [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 |
|
||||
|
||||
### Simplification
|
||||
|
||||
@@ -54,12 +55,18 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
|
||||
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
|
||||
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
|
||||
| [Ask-user question capability](implemented/feature/2026-06-25-ask-user-question.md) | 2026-06-25 |
|
||||
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
|
||||
| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
|
||||
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
|
||||
| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
|
||||
| [SessionStore fork API](implemented/feature/2026-06-30-session-store-fork-api.md) | 2026-06-30 |
|
||||
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
|
||||
| [Skill system — progressive disclosure instructions for agents](implemented/feature/2026-07-05-skill-system.md) | 2026-07-05 |
|
||||
| [Explicit model-facing tool order](implemented/feature/2026-07-06-explicit-tool-order.md) | 2026-07-06 |
|
||||
| [The session prefix — request-only messages in front of the derived history](implemented/feature/2026-07-07-session-prefix.md) | 2026-07-07 |
|
||||
| [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 |
|
||||
|
||||
### Simplification
|
||||
|
||||
@@ -120,6 +127,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Prompt variables and tool-guidance ownership](implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 2026-07-05 |
|
||||
| [Every LLM request is reconstructable from the session log](implemented/architecture/2026-07-05-reconstructable-requests.md) | 2026-07-05 |
|
||||
| [Subagent provider-lifecycle events — `subagent/provider-added` / `subagent/provider-removed`](implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md) | 2026-07-05 |
|
||||
| [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 |
|
||||
| [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 |
|
||||
|
||||
### Process
|
||||
|
||||
@@ -143,6 +152,11 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 |
|
||||
| [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 |
|
||||
| [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 |
|
||||
| [Export-surface JSDoc gate](implemented/process/2026-07-06-export-surface-jsdoc-gate.md) | 2026-07-06 |
|
||||
| [Generated plugin config catalog](implemented/process/2026-07-06-generated-config-catalog.md) | 2026-07-06 |
|
||||
| [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 |
|
||||
|
||||
### Testing
|
||||
|
||||
@@ -157,6 +171,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 |
|
||||
| [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 |
|
||||
| [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 |
|
||||
| [Pin request-header content in one snapshot scenario](implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md) | 2026-07-06 |
|
||||
| [Extract the ACP snapshot suite into a support package](implemented/testing/2026-07-08-shared-acp-snapshot-package.md) | 2026-07-08 |
|
||||
|
||||
## Rejected
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ flowchart LR
|
||||
|
||||
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages.
|
||||
|
||||
Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), 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 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.
|
||||
|
||||
`@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.
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ 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-content half — `EpochHeader`: call config (`LlmCallConfig`: model + sampling scalars), rendered system prompt, assembled tool schemas — is logged session state, in canonical form (empty system/tools ≡ 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). 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.
|
||||
**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.
|
||||
|
||||
**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) → `agent/pre-step` (compaction's surface mutations land before derivation) → **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; content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log → build `GenerateOptions` from the snapshot + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's only in-process bookkeeping is one boolean: whether this instance has logged its anchoring snapshot.
|
||||
**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.
|
||||
|
||||
**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, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `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.
|
||||
|
||||
**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 boundary 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. `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.** 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.
|
||||
|
||||
### The MiniCode shape: adopted, with the provenance arrow inverted
|
||||
|
||||
@@ -44,6 +44,7 @@ What survives from `LLMClient`: the conversation is maintained, not rebuilt —
|
||||
## Consequences
|
||||
|
||||
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
|
||||
- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern).
|
||||
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replace node), a real prompt/tool change (`request/header-delta`), a config switch (ditto), a process boundary with drift (`'resume'` snapshot differing from its predecessor). The provider's own reasoning-content exclusion is managed server-side.
|
||||
- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam.
|
||||
- Tool-result trimming (planned) needs no new mechanism: a logged single-node surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# RFC: A shared timeout/deadline primitive, with hard-kill left to each capability
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden.
|
||||
|
||||
- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently.
|
||||
- **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`.
|
||||
- **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.)
|
||||
|
||||
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.
|
||||
|
||||
### The library surface
|
||||
|
||||
Three functions plus one reason type:
|
||||
|
||||
```ts ignore-check
|
||||
/** The internal reason attached to a timeout abort, so consumers can classify it after the fact. */
|
||||
export class TimeoutReason extends Error {
|
||||
override name = 'TimeoutReason'
|
||||
|
||||
constructor(readonly code: string, readonly timeoutMs: number) {
|
||||
super(`${code} after ${timeoutMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate/fill a caller's optional positive hint from the backend's default, then cap at its max. */
|
||||
export function clampTimeout(
|
||||
requested: number | undefined,
|
||||
def: number,
|
||||
max: number,
|
||||
name = 'timeoutMs',
|
||||
): number
|
||||
|
||||
/**
|
||||
* Build a deadline signal that aborts on upstream cancellation OR on timeout,
|
||||
* with the timeout carrying a `TimeoutReason`. `timeoutMs <= 0` means "no
|
||||
* timeout" (background tasks): forward only the upstream signal, arm no timer.
|
||||
* The returned object's `[Symbol.dispose]` clears the timer — `using` for a
|
||||
* scope-lifetime consumer, a manual call for an event-lifetime one.
|
||||
*/
|
||||
export function deadline(
|
||||
upstream: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
code: string,
|
||||
): { signal: AbortSignal; [Symbol.dispose](): void }
|
||||
|
||||
/** Recover the TimeoutReason from an aborted signal (or error); `code` scopes the match to this deadline's timer. */
|
||||
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.
|
||||
|
||||
### The division of labor
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Validate request hint and clamp default/max | `dsh-timeout` (`clampTimeout`) — pure arithmetic plus the shared positive-finite request contract |
|
||||
| Arm timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) |
|
||||
| Clear the timer | `dsh-timeout` (`[Symbol.dispose]`) |
|
||||
| Classify the first abort reason after abort | `dsh-timeout` (`timeoutOf`) |
|
||||
| **Actually terminate the work** | the capability's implementation |
|
||||
| The default/max *values* | the capability's config |
|
||||
| The timeout `code` string | the capability (`WEB_FETCH_TIMEOUT` ≠ `BASH_TIMEOUT`) |
|
||||
|
||||
The signal only *notifies*; termination is always the listener's job, and the listener differs by capability. bash writes its own `addEventListener('abort', kill)` because the OS process lives outside this runtime and nothing else will kill it; web hands `d.signal` to `fetch` and undici tears down the socket. This is why file read/write/edit take **no** `timeoutMs`: a local syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. Both reference agents leave file I/O untimed for the same reason.
|
||||
|
||||
### 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`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `runBash`'s outcome no longer independently latches `timedOut` and `aborted`; a timeout and a user abort racing before process close now report a single first-abort cause instead of both being true. The uniform SIGTERM→grace→SIGKILL kill is unchanged, and the seam type `BashRunResult` keeps both booleans (now mutually exclusive), so `dsh-tool-bash`'s result rendering is untouched.
|
||||
- `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. This is the one deviation from the literal proposal shape (which passed `timeoutMs: 0` into `runBash`); an always-0 field read by nothing is dead weight under the per-file coverage gate.
|
||||
- web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`.
|
||||
- `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met).
|
||||
|
||||
Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; future ripgrep-backed fs discovery tools can consume the same provider-owned deadline shape once they exist; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A unified timeout *plugin* / `ctx.timeout` service.** Rejected on microkernel grounds. A service that could stop any tool's work would have to understand every capability's termination mechanism (process-group SIGKILL, socket teardown, syscall-boundary checks) — the "kernel knows too much" the architecture forbids. Codex's `ExecExpiration` is scoped to the exec family precisely because the kill it drives (`killpg`) is process-family-specific; MCP and model-stream keep their own. There is no coherent middle layer that owns termination for everything, so the shared piece can only be the pure timing/classification half — a library, not a service.
|
||||
|
||||
**Per-tool ad-hoc timeout, no shared code (the prior status quo, and Claude Code's choice).** Rejected because it was already producing divergence and duplicated correctness burden: web_fetch hand-rolled the exact controller/reason logic that future network/process-backed tools would each have to re-derive, and the fusion + `signal.reason` recovery are the error-prone parts. Claude Code tolerates full duplication; this repo has a single shared abort channel (`exec.signal` on every `execute`) that makes a small shared primitive strictly cleaner, so the cost/benefit differs.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,110 @@
|
||||
# RFC: Tool-call timeout policy as a plugin
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The [timeout/deadline RFC](2026-07-06-timeout-deadline-library.md) extracted the timing-and-classification primitive into `@deepseek-ai/dsh-timeout`, but timeout policy was still attached to individual capabilities and model-facing schemas. `bash` exposed `timeoutMs`; `web_fetch` exposed `timeout_ms`; `web_search` had no model-facing timeout even though providers already honor `exec.signal`; a future grep/glob tool would either import the timeout library directly or invent its own timeout policy. That is the wrong authoring shape for a plugin SDK: a tool author should normally forward `exec.signal` to the implementation it calls, and deployment policy should decide the budget.
|
||||
|
||||
At the same time, not every timeout in the repo is a model-facing tool-call budget. Hooks execute command hooks by calling `ctx.bash` directly, not through `ctx.tools.execute()`, and the `bash` model tool multiplexes foreground execution, background start, background polling, and hook reuse through the same backend. Moving every timeout into a tool plugin in one step would conflate those paths and risk breaking hook timeout semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
Tool-call timeout is a policy that applies only to model-facing tool execution, in three parts:
|
||||
|
||||
- `@deepseek-ai/dsh-timeout` remains the shared library that owns `deadline()` and `timeoutOf()`.
|
||||
- `@deepseek-ai/dsh-tools` has an around-dispatch waterfall, `tools/execute`, between `tools/pre-execute` and `tools/post-execute`.
|
||||
- `@deepseek-ai/dsh-timeout-policy` reads each tool's declared `timeoutMs` from the registry and wraps a call that has one by deriving a new `exec.signal`.
|
||||
|
||||
The execution pipeline is:
|
||||
|
||||
```text
|
||||
ctx.tools.execute(exec)
|
||||
-> tools/pre-execute
|
||||
-> tools/execute
|
||||
-> registry dispatch (the base next())
|
||||
-> tool.execute(args, exec)
|
||||
-> thrown tool errors normalize to ToolExecutionResult
|
||||
-> tools/post-execute
|
||||
```
|
||||
|
||||
The default behavior is conservative: a tool that declares no `timeoutMs` receives no `TOOL_TIMEOUT` deadline from the plugin.
|
||||
|
||||
### The `tools/execute` around seam
|
||||
|
||||
`@deepseek-ai/dsh-tools` declares a `tools/execute` waterfall whose base `next()` is the dispatch-with-normalization thunk — the same inner `try`/`catch` that turns a thrown tool (or unknown tool) into an `isError` `ToolExecutionResult`. A listener receives `(exec, next)`: it calls `next()` to delegate to dispatch (returning its result, optionally wrapped) or returns a replacement result to short-circuit dispatch. The whole pipeline still sits inside `execute`'s outer try/catch, so a throwing listener becomes an `isError` result, never a turn failure.
|
||||
|
||||
That the catch is the base `next` — not something outside the waterfall — is load-bearing: when a provider sees the timeout signal and throws its own upstream-abort error, registry dispatch first converts it to a normal error result, and only then can `timeout-policy` replace the final result with `TOOL_TIMEOUT`.
|
||||
|
||||
### The `timeout-policy` plugin
|
||||
|
||||
The plugin is `@deepseek-ai/dsh-timeout-policy`, a zero-config function/namespace plugin (`name` / `inject` / `apply`) in the `packages/timeout/` group. The per-tool budget is DECLARED on the tool, not on this plugin: a `ToolDefinition` carries an optional `timeoutMs`, which the owning tool plugin sets from its own config. `dsh-tool-web`, for example, resolves `fetchTimeoutMs` / `searchTimeoutMs` (default 30000) onto the `web_fetch` / `web_search` definitions:
|
||||
|
||||
```yaml
|
||||
- id: timeout-policy
|
||||
name: '@deepseek-ai/dsh-timeout-policy'
|
||||
- id: tool-web
|
||||
name: '@deepseek-ai/dsh-tool-web'
|
||||
config:
|
||||
fetchTimeoutMs: 30000
|
||||
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.
|
||||
|
||||
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 {
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
|
||||
isError: true,
|
||||
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a cooperative deadline. It does not kill arbitrary work by racing the tool promise; the tool or the capability it calls must honor `exec.signal` and reach quiescence. Declaring `timeoutMs` therefore MEANS "this tool is cooperative with `exec.signal`", which the plugin README states as its contract.
|
||||
|
||||
No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the final model-facing `tool/result` for that call, so the existing session log already records the content and structured `{ name, code }` error the next model request sees.
|
||||
|
||||
### Existing tool adaptation
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Name the plugin `tool-timeout`.** The literal RFC name matched the `gen-tool-catalog` completeness guard's `packages/*/tool-*` glob, which requires every match to register a model-facing tool. This plugin registers none — it is a `tools/execute` wrapper — so a `tool-*` name would either fail `verify-tool-catalog` or force a misleading boot entry. The package is `@deepseek-ai/dsh-timeout-policy` in a new `packages/timeout/` group; the cordis.yml `id` can still be `timeout-policy`.
|
||||
|
||||
**Keep per-tool timeout handling only.** This was the shape for `bash` and `web_fetch`, and it matches Claude Code and Codex for shell commands. It loses for web-style tools because every new timeout-capable tool must choose validation, cap semantics, docs, snapshots, and classification. The plugin centralizes policy and classification while leaving each tool's schema focused on business input.
|
||||
|
||||
**Move all timeout policy out of bash-local immediately.** Cleaner long-term — bash-local would become a pure subprocess executor and all callers would own their deadlines. It loses as the first step because hooks call `ctx.bash` directly and the bash model tool has foreground/background semantics that are not the same tool-call lifetime. Keeping `BASH_TIMEOUT` preserves those paths while tool-call timeout proves itself on simpler tools.
|
||||
|
||||
**Use a global default budget for every tool.** Convenient, but it surprises tool authors: any tool that accidentally runs longer than the global budget would start failing once the plugin loads. A per-tool declared budget makes adoption deliberate.
|
||||
|
||||
**Expose a model-facing `timeout_ms` override.** Claude Code's `WebFetch`/`WebSearch` and Codex's web tools keep timeout out of the model-call shape. A model override would make timeout part of prompt semantics and force schema/argument-stripping rules into `timeout-policy`. Web timeout stays deployment policy only.
|
||||
|
||||
**Let `timeout-policy` match tool arguments itself.** A rule engine such as "disable timeout when `bash.run_in_background` is true" would make the policy plugin know tool-specific argument semantics. Avoided by not migrating bash to tool-call timeout.
|
||||
|
||||
**Use `tools/pre-execute` plus `tools/post-execute` instead of a new around seam.** A pre listener could arm a deadline and mutate `exec.signal`; a post listener could classify and replace. That loses because the deadline lifetime would cross two independent waterfalls: a call-id map, cleanup on every pre-deny/tool-throw/post-throw/dispose path, and ordering rules with every other listener. `tools/pre-execute` is also the allow/deny gate, not an execution wrapper. `tools/execute` gives the timeout one lexical scope: arm, delegate, classify, dispose.
|
||||
|
||||
**Use `Promise.race` to enforce timeouts for non-cooperative tools.** Rejected for the same reason as the timeout-library RFC: it returns control to the caller while the underlying process, fetch, or provider operation may still be running. The plugin only sends a signal; termination remains the implementation's responsibility.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `@deepseek-ai/dsh-tools` gains an around-dispatch surface after the interception seams deliberately split pre/post tool hooks. Its contract is narrow — wrap registry dispatch, not replace the pre-gate or post-result policy — and the base `next()` is dispatch-with-normalization so a wrapper never sees a raw tool throw.
|
||||
- Multiple `tools/execute` listeners compose by ordinary Cordis waterfall order: a listener that calls `next()` wraps downstream listeners plus dispatch; one that returns without `next()` short-circuits them. A deployment combining timeout with a future retry/sandbox/metrics wrapper chooses semantics by registration order ("timeout covers the whole retry" vs "timeout covers each attempt").
|
||||
- Opt-in by declaration is a deliberate misconfiguration risk: a tool can declare a `timeoutMs` without honoring `exec.signal`, and that tool will not stop on timeout. The plugin contract states that declaring a budget means cooperative; the web tools prove the pattern on tools that already forward the signal.
|
||||
- During the transition `bash` and the migrated web tools use different timeout paths on purpose: `TOOL_TIMEOUT` is the model-facing tool-call budget, while `BASH_TIMEOUT` remains the bash backend timeout used by bash and hooks.
|
||||
- Deviation from the literal proposal, recorded per the implemented-RFC rule: the plugin package is `@deepseek-ai/dsh-timeout-policy` (not `tool-timeout`), signal replacement is in-place `exec.signal` mutation before `next()` (not `next({ ...exec, signal })`, which cordis ignores), and the per-tool budget is declared on the `ToolDefinition` (`timeoutMs`, set by the owning tool plugin from its config) rather than mapped by tool name in this plugin's config — so the enforcer is zero-config and a mistyped tool name is impossible. All three are described in `## Decision` above.
|
||||
@@ -0,0 +1,49 @@
|
||||
# RFC: Ask-user question capability
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The agent sometimes cannot proceed safely from model inference alone: it needs the human to choose a path, confirm a risky/default action, or provide missing information. Before this change, the only way to get that answer was for the model to ask in assistant text and then stop, which broke the normal tool-call loop: the agent had no structured way to pause, no option metadata for UIs, no abort/error taxonomy, and no way for non-stdio front doors to present the question consistently.
|
||||
|
||||
This is a user-facing capability, but it also crosses package boundaries. A model-facing tool needs a provider-neutral request vocabulary; each UI surface needs to decide how to show and collect the answer; the agent loop should remain unchanged because a tool call already has the right async shape.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce `dsh-user-interaction` as the provider-neutral interface package for `ctx.userInteraction`, colocated with the model-facing consumer `dsh-tool-ask-user` under `packages/ui`. The grouping is intentional: asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam still owns the stable request/answer/error vocabulary, while UI product surfaces provide the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ questions, agent, signal }`, and returns the provider-computed structured answers as the tool result.
|
||||
|
||||
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
|
||||
|
||||
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
|
||||
|
||||
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
|
||||
|
||||
## 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-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.
|
||||
|
||||
The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Assistant text followed by a stopped turn.** The model could ask the user in plain assistant text and then stop. That loses the structured option metadata, gives UIs no provider-neutral way to render a choice, and forces the next human answer to arrive as a new user prompt rather than as the result of the operation that needed the answer.
|
||||
|
||||
**Core-owned ask-user packages.** The first implementation split the seam and the model-facing tool across `packages/core` and `packages/ui`, but both names describe one UI-backed human-interaction affordance. The seam remains provider-neutral, but it is not providerless core infrastructure like sessions, tools, or the agent registry. Keeping `dsh-user-interaction` and `dsh-tool-ask-user` together under `packages/ui` makes the package map match the product boundary: apps and bridges provide the human-answer provider, and the stdio app opts into the model-facing tool.
|
||||
|
||||
**ACP `session/request_permission`.** Permission requests are authorization around tool execution; `ask_user_question` is information gathering with optional free-form answers. Using permission for general questions would collapse two different product concepts and make the future permission gate harder to reason about.
|
||||
|
||||
**A loop-level pause primitive.** The agent loop already knows how to await a tool call and resume from a tool result. Adding a new loop special case would duplicate that async shape and make every loop implementation learn about a UI concern.
|
||||
|
||||
## Consequences
|
||||
|
||||
ACP elicitation is currently marked unstable in the SDK. The fallback is still structured: if a client does not implement it, the tool returns `ASK_FAILED` rather than hanging. A later ACP stabilization may rename or reshape the method; that migration should stay inside `dsh-acp` because the core `ctx.userInteraction` vocabulary is provider-neutral.
|
||||
|
||||
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
|
||||
|
||||
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `stdio-agent` opts into the seam, its readline provider, and the model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,41 @@
|
||||
# RFC: SessionStore fork API
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around which prefix can be copied, which metadata is stamped on the child, and how errors are classified.
|
||||
|
||||
The semantic hazard is the fork boundary. A valid user-visible fork seed must be contiguous and turn-enclosed. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: tool-triggered subagent forks usually happen while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should either fork the requested boundary or reject it.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-session` owns ordinary live-session forking directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the API has no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends.
|
||||
|
||||
The store exposes one operation:
|
||||
|
||||
```ts ignore-check
|
||||
type SessionForkSource = Session | SessionId
|
||||
|
||||
class SessionStore extends Service {
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
}
|
||||
```
|
||||
|
||||
`boundary` is the inclusive source event `seq` to copy through. When omitted, it defaults to the source session's current last event; on an empty source, omitted `boundary` creates an empty child. Fork-specific validation only checks that the requested boundary exists and is a `turn/end`. The selected prefix is then deep-cloned into the child seed. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the copied prefix length. When `childSessionId` is omitted, `SessionStore` generates one using its existing id policy.
|
||||
|
||||
The boundary rule is structural: an empty selected prefix is forkable, and any non-empty selected prefix must end at `turn/end`, regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). A boundary that is not an existing event seq, is not a safe integer, or does not point at `turn/end` is rejected with a typed `SessionForkError` code. Broader session-log sanity remains in the existing invariant/repair layers: `dsh-invariants` checks turn enclosure and richer event ordering in dev, while persistence repair handles the valid crash-tail case of a final interrupted turn. The API also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), duplicate requested child ids (`SESSION_ALREADY_EXISTS`), and invalid boundary values (`INVALID_BOUNDARY`).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive.
|
||||
|
||||
**Two functions: `snapshot()` plus `fork()`.** This preserved a reusable seed/metadata computation, but the only supported consumer created a session immediately. It also made the surface feel more abstract than the concrete operation users need. A single `fork()` with an explicit `boundary` keeps the API direct while still supporting previous-point forks.
|
||||
|
||||
**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail.
|
||||
|
||||
## Consequences
|
||||
|
||||
The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
|
||||
|
||||
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage.
|
||||
@@ -0,0 +1,49 @@
|
||||
# RFC: Explicit model-facing tool order
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The order of the tool list a model call carries — `request/header.tools` on the session log and `GenerateOptions.tools` on the wire — was an emergent artifact: the tool registry returns schemas in registration order, the system-prompt assembly concatenates providers in registration order, and the loop logged and dispatched the result verbatim. Registration order is plugin load order, and plugin load order is a race: the cordis loader imports every `cordis.yml` entry concurrently, so which tool plugin registers first depends on module-import completion timing. The plugin dependency relation cannot rescue this — it is a partial order under which independent tool plugins (e.g. `tool-subagent` vs `tool-todo`) are incomparable, so both interleavings are legal linearizations. This stopped being theoretical when a CI runner resolved the race differently from every recording machine: snapshot goldens pinned one permutation of `request/header.tools`, the `node 22.18` CI leg produced the other, and 5/5 snapshot tests failed on a diff that was pure array reordering. Tool order is part of the request bytes (prompt-cache stability, potentially model behavior) and, since the reconstructability contract, part of the durable session log — it must be a decision, not a residue.
|
||||
|
||||
## Decision
|
||||
|
||||
The system-prompt assembly owns the canonical model-facing tool order, exactly where it already owns section order. `toolOrder?: string[]` on `dsh-system-prompt` is the optional explicit policy:
|
||||
|
||||
- A listed tool that is registered takes its listed position.
|
||||
- A listed name with no registered tool is a configuration error. Shape errors (rest entry missing or duplicate names) fail from the service constructor; an unregistered name rejects every `assemble()` — the earliest moment the registered tool set exists to check against (tool plugins register after the service constructs), and the only universal one (registrations can change at any time; cordis has no "all plugins loaded" event). Under the shipped loop the first turn fails before any model request — see the consequences below for the exact blast radius.
|
||||
- A registered tool absent from the list is inserted at the `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`), in lexicographic name order among the other unlisted tools.
|
||||
- No collected tool may use `TOOL_ORDER_REST` as its `ToolSchema.name`; the assembly rejects that reserved name before ordering.
|
||||
- The list must contain the rest entry exactly once and no duplicate names.
|
||||
- When `toolOrder` is unset, the canonical order is plain lexicographic name order (code-unit comparison, locale-independent), so determinism requires no configuration.
|
||||
|
||||
The policy is applied where the list is born: `assemble()`, before the `system-prompt/assemble` waterfall. The assembly canonicalizes the tools it collects from providers the same way it sorts sections by their `order` field — on the initial assembly, killing the registration-order entropy at its source. Everything downstream inherits the order untouched: the waterfall, the loop's `EpochHeader`, the `request/header` event, the deep-frozen request, and the dev invariant's cross-check all see one deterministic list, with no new loop change.
|
||||
|
||||
Scope is deliberately narrow: this fixes the REGISTRATION-ORDER race, not plugin behavior. A `system-prompt/assemble` listener may still add, remove, or rearrange tools — same as it may edit sections after their sort — and owns the determinism of what it emits; the waterfall contract already demands deterministic listeners (the reconstructability invariant would catch a listener that diverges between build and replay).
|
||||
|
||||
Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: the app configs (`dsh-stdio-agent`, `dsh-acp-agent`) accept the key and forward it through `dsh-agent-core` (whose schema is the intersection of the owners' schemas) to the `SystemPrompt` child. One schemastery footnote is load-bearing: a schemastery array defaults to `[]`, but an omitted `toolOrder` must stay ABSENT (= lexicographic) rather than become an explicitly-configured empty list (invalid — it lacks the rest entry), so every schema on the chain forces the default to `undefined`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Registration order (the status quo)** — a concurrent-import race, host-dependent (the CI flake above), invisible in review.
|
||||
- **A linearization of the plugin dependency graph** — the relation is partial and independent tool plugins are incomparable; the flake happened with the partial order fully satisfied.
|
||||
- **Per-plugin `weight` on each tool contribution** — scatters the order across plugins yet still needs a global numbering convention nobody owns (the section `order` bands show that coordination cost being paid by hand).
|
||||
- **Sorting in `ToolRegistry.schemas()` (the registry layer)** — equally deterministic, but the registry is a membership store consumed by more than the assembly; ordering is a prompt-composition concern, and the assembly already owns the composition policy for sections.
|
||||
- **A `LlmService` config + `orderTools()` method the loop calls before logging the header** — works, but adds a public service method and a loop edit solely to apply a policy at a distance; every future request composer must remember the call. Canonicalizing where the list is born makes an unordered list unrepresentable, with zero new surface.
|
||||
- **Normalizing inside `llm.stream()`** — runs after the header event is logged (the flake survives) and rebuilds the deep-frozen envelope, silently disarming the reconstruction invariant.
|
||||
- **An exhaustive list (no rest entry)** — every newly loaded tool plugin would break boot; the mandatory rest entry keeps unlisted tools deterministic and their position explicit.
|
||||
- **A boot-time validation pass (a `SystemPrompt.assertToolOrderSatisfied()` called by `dsh-app-boot` after `loader.await()`)** — would turn the misconfiguration into a startup death instead of a first-turn failure, but costs a public service method plus a structural coupling from the generic boot glue to one service, and cannot replace the assembly-time check anyway (embedded callers never run app boot; registrations change after boot). No existing event can host the check either: cordis v4 has no ready-like event, `loader/entry-init`/`internal/status` fire mid-load (racy against tool registration, the very entropy this RFC kills), and the agent lifecycle events are no earlier than the assembly. One enforcement point at `assemble()` was judged worth the later failure moment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every assembly — and therefore every `request/header` event and model request — has a deterministic tool order on every host; the CI-vs-local golden flip is structurally gone. The default order is lexicographic, no longer registration order.
|
||||
- `PromptAssembly.tools` itself is canonical, so every assembly consumer (the loop, waterfall listeners, any future prompt inspector) sees the model-facing order; provider registration order is observable nowhere downstream of the registry.
|
||||
- The snapshot suite's single pinned request-header fixture (`text-turn`) carries the new canonical tool order; every other ACP snapshot keeps the header bulk scrubbed as `{{system}}`/`{{tools}}`, per the pinned-header design.
|
||||
- A pure tool reordering between steps is representable only as a `request/header` `'fallback'` snapshot (the name-keyed `ToolsDelta` cannot express it); with a stable canonical order such reorders no longer occur in practice, so the fallback path stays a safety valve.
|
||||
- The `toolOrder` key rides the app → `agent-core` → `SystemPrompt` forwarding chain, so deployments set it next to `persona` in the app config; `dsh-llm` and the agent loop are untouched.
|
||||
- A misspelled or unloaded tool name in `toolOrder` fails the turn at prompt assembly, not the boot: the loop assembles inside the turn (after `turn/start`, before `step/start`), so the rejection reaches the turn's outer catch — the turn closes balanced with an `error` reason carrying the message, `agent/error` mirrors it, no step opens, no `request/header` is logged, no request reaches the adapter, and the agent returns to idle. Every turn fails identically until the config is fixed; the process itself stays up (matching the repo rule that explicit config references must not be silently ignored — the enforcement point is the assembly because no earlier universal moment exists).
|
||||
- A tool provider that returns the reserved rest-entry name has the same prompt-assembly failure shape as an unknown listed name. This keeps the sentinel from becoming an ambiguous real tool and preserves the "never drops a tool" ordering contract.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests on `dsh-system-prompt` pin the ordering semantics (lexicographic default, listed/rest placement, unknown-name rejection at assembly, reserved tool-name rejection, stable handling of shared names, provider-order independence), the pre-waterfall contract (listeners observe the canonical list; a listener-appended tool is not re-sorted), and each invalid-list rejection at load. Loop-level tests assert the `request/header` fold carries the canonical order for scrambled registration orders (identical across permutations), that a configured `toolOrder` reaches both the logged header and the dispatched deep-frozen request, that the frozen loop-built envelope survives to the adapter, and that an unregistered `toolOrder` name fails the turn with a balanced `error` `turn/end`, an `agent/error`, no step, no logged header, and no dispatched request. Forwarding is asserted at every level that exposes the key (`dsh-agent-core`, `dsh-stdio-agent`, `dsh-acp-agent`). The snapshot tier replays all scenarios while only the pinned `text-turn` header carries the full canonical tool list; non-pinning fixtures continue to compare through `{{tools}}`.
|
||||
@@ -0,0 +1,42 @@
|
||||
# RFC: The session prefix — request-only messages in front of the derived history
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role `<system-reminder>` envelope, a multi-message primer) does not fit it, and providers weight conversation messages differently from system text. Durable history (`agent.inject()`, a `context/message` at session start) makes the opener permanent: every `deriveMessages()` consumer replays it, the compaction retention walk owns it, forks bake it in stale, and a resume cannot refresh it — a catalog captured at session birth outlives the world it described.
|
||||
|
||||
The obvious third option — let a plugin edit the request's `messages` on the way out — is banned by [the reconstructable-requests RFC](../architecture/2026-07-05-reconstructable-requests.md): every loop-built request is a pure function of the session log, so whatever channel carries the opener must log exactly what it sends. What was missing was a request-only message channel with a durable record.
|
||||
|
||||
## Decision
|
||||
|
||||
`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)).
|
||||
|
||||
Three properties carry the design:
|
||||
|
||||
- **Request-only, header-logged.** `deriveMessages()` never returns the prefix; its one durable record is `EpochHeader.messagePrefix` on the instance's anchoring `request/header` snapshot — the channel the reconstructable-requests RFC already owns for the request's non-history half, so no new session event exists. The dev invariant ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)) recomputes `messagePrefix + boundary derivation` against every loop-built request; an unlogged prefix cannot reach the wire.
|
||||
- **Frozen per instance.** Reuse is structural, not disciplined: the cached product cannot change mid-session, so the provider's prompt cache holds by construction and the prefix extends the cacheable region at zero marginal cost per step. A process restart or `ctx.agents.resume()` is a new instance: it recomposes, and any drift lands attributably on the `'resume'` header snapshot. This is the routing rule the seam creates: session-frozen openers ride the prefix; content that changes mid-session rides the append-only history channels (`agent.inject()`, a `tools/post-execute` decision's `additionalContext`, prompt-submit `additionalContext` — [the interception-seams RFC](2026-06-30-interception-seams.md)), each a durable `context/message` paid once and prefix-cached thereafter.
|
||||
- **Composed before the pressure gate.** Composition precedes the instance's first `agent/pre-step`, and the seam hands the composed value through: `agent/pre-step` carries a `sessionPrefix` parameter and `CompactService.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` counts it in its token-pressure estimate — a gate reading the previous instance's folded prefix instead would under-gate a resumed or forked instance whose contributor grew, skipping compaction and shipping an over-window first request. A composition interrupted by a cancel/dispose landing inside the waterfall is discarded, never cached: an abort-aware listener's degraded fallback cannot leak into later requests, and the next turn recomposes under a live signal.
|
||||
|
||||
Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit** — [interception.spec.ts](../../../../packages/core/agent-loop/tests/interception.spec.ts) pins compose-once across turns and steps (one composition, zero `request/header-delta`s), canonical prepend ordering, empty-prefix omission from the header, the frozen seed (in-place push throws), held-reference mutation immunity, and composition-precedes-pre-step with the seam receiving the composed value; [cancel.spec.ts](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pins cancel/dispose landing inside the composition window and the discard-and-recompose stale-cache guard; dsh-session codec tests cover the `messagePrefix` fold/diff/apply arms (empty ≡ absent); dsh-invariants tests pin the `messagePrefix + derivation` equation; dsh-compact-basic tests pin that the pressure estimate counts the handed prefix. **Snapshot** — the acp-snapshot normalizer scrubs header prefixes to count-preserving `{{messagePrefix}}` tokens (unit-covered in dsh-acp-snapshot); header content itself is pinned per [the pinned-header scenario RFC](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md), and the example tree loads no prefix contributor, so live goldens stay prefix-free. **e2e** — none prefix-specific: the seam is provider-independent and deterministic; the with-key cache measurement in [request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) already proves the cacheable-prefix economics the design rests on.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites silent drift — nothing anchors it to the log short of logging a header delta per step — and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation.
|
||||
- **A system-prompt section** (`system-prompt/assemble`) — rejected for this content: the assembly renders to the single `system` string, so message-shaped openers do not fit, and the system prompt is deliberately re-assembled per step (with header deltas when it changes) while the opener wants instance-frozen semantics.
|
||||
- **A durable history opener** (`inject()` at session start) — rejected: permanent history is the failure mode in the problem statement — replayed everywhere, compactable, stale across resumes.
|
||||
- **Compose per turn instead of per instance** — rejected: a turn-boundary recompose either desyncs silently from the log or forces a header delta per change, and it busts the provider cache exactly as often as it fires; the legitimate refresh point is the instance boundary, where the `'resume'` snapshot already records drift attributably.
|
||||
- **Compose lazily at the first request and let compaction read the folded header** (the shape as first merged) — superseded in review: the fold matches the live prefix only from the instance's second request on, so on a resumed/forked instance's first step the pressure gate read the PREVIOUS instance's prefix and could under-gate. Composing before the first pre-step and handing the live value through the seam makes the estimate exact at every step.
|
||||
- **A dedicated session event carrying the prefix** — rejected: the header events are the request's non-history record by design; a second event would be a second home for the same fact and another codec to keep total.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `agent/pre-step` and `CompactService.compactIfNeeded` carry a `sessionPrefix` parameter: every pre-step listener and compaction backend sees the real per-instance value (all in-repo implementations updated in the same change, per the pre-release stance).
|
||||
- A contributor whose content changes mid-session is not re-read until the next instance — by design. A deployment needing mid-session catalog updates routes the change notice through the append-only history channels and pays one durable `context/message`.
|
||||
- The dropped `after` slot leaves no request-only channel near the request tail; nothing in the repo needs one, and adding it back would re-open the every-step re-pay cost the design exists to avoid.
|
||||
- The `request/header-delta` `messagePrefix` arm (whole-array replacement, empty array encoding transition to absence) exists for codec totality; the loop never exercises it, because the cached prefix cannot change within an instance.
|
||||
- An empty composition is canonical absence: no-contributor deployments log no extra header bytes and their requests are the bare derivation.
|
||||
@@ -0,0 +1,73 @@
|
||||
# RFC: Repeat-tool-call guard plugin
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A model stuck in a loop re-issues the same tool call with byte-identical arguments — re-running a failing grep, re-reading an unchanged file, polling a command that already gave its answer — and each round trip burns tokens, wall-clock, and (for paid APIs) money without adding information. The harness has nothing that notices: the loop has no step budget, no plugin tracks call repetition, and the model only escapes when it happens to vary its own behavior. The failure mode is real and cheap to detect — [pi-repeat-tool-guard](https://github.com/Kingwl/pi-repeat-tool-guard) ships exactly this as a pi coding-agent extension: count consecutive identical calls and, past a threshold, append a `<system-reminder>` telling the model to stop repeating itself and change course.
|
||||
|
||||
The harness already has every seam the pi extension uses, and better ones: [the interception-seams RFC](2026-06-30-interception-seams.md) gives `tools/post-execute` a sanctioned way to attach model-facing context to a finished call, the loop buffers and injects that context with call/result adjacency preserved, and injected context is a logged `context/message` — so a native guard satisfies the model-visible ⟺ logged rule with no new session event. What was missing was only the plugin itself.
|
||||
|
||||
## Decision
|
||||
|
||||
The guard is a loop-hygiene plugin, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The purpose is to break unproductive loops within a few wasted steps instead of letting them run to the turn's natural end — while leaving the decision (retry differently, gather more evidence, or finish) entirely with the model, so a legitimately repeated call is delayed by nothing and blocked by nothing.
|
||||
|
||||
The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish.
|
||||
|
||||
- **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, folds a reminder onto the downstream decision's `additionalContext` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking.
|
||||
- **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop.
|
||||
- **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime.
|
||||
|
||||
### Detection semantics
|
||||
|
||||
The chain key is `(tool name, canonical arguments)`; a call identical to the previous tracked call increments the agent's consecutive counter, a different tracked call resets it to 1. Canonicalization is a deep key-sort plus `JSON.stringify`: `ToolExecution.arguments` is by construction the loop's `JSON.parse` output (or the raw string fallback for malformed argument JSON, which is itself a comparable value), so the pi original's bigint/circular/`undefined` handling has no inputs here and is deliberately dropped.
|
||||
|
||||
Two deliberate rules, both documented in [the package README](../../../../packages/guard/repeat-tool-guard/README.md) because they are behavior a reader would otherwise guess at:
|
||||
|
||||
- **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down.
|
||||
- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no `AgentId` to key on.
|
||||
|
||||
### Reminder delivery
|
||||
|
||||
Reminders ride `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}` — the label is load-bearing per `HookContext`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit, and the loop appends buffered context as `context/message`(s) after the step's results, which the session renders as the tagged synthetic-user envelope and derived history replays. Thresholds escalate: the first configured threshold gets a short "you are repeating yourself, analyze the previous result" nudge; each later threshold gets the detailed form naming the tool, the repeat count, and the canonical arguments (head-truncated at `argumentsPreviewChars`, default 500 — a looping `write`-sized payload must not ride into the next request unbounded; the chain key always compares the full canonical string), and stating that the calls made no progress. The pi original hardcodes the gentle text to the literal count 3; the guard keys it to `thresholds[0]`, fixing that bug in the port. When the downstream decision already carries `additionalContext` (a hook bridge on the same call), the guard concatenates content under its own `source` — a `HookContext` holds one `MessageSource`, and `source.kind` is what framing depends on.
|
||||
|
||||
### Config
|
||||
|
||||
```yaml
|
||||
- id: repeat-tool-guard
|
||||
name: '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
config:
|
||||
thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
|
||||
include: [] # tool-name patterns to track; empty ⇒ all tools
|
||||
exclude: [todo_write] # tool-name patterns transparent to the chain
|
||||
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
|
||||
```
|
||||
|
||||
`thresholds` is validated at load and throws on an empty list, a non-integer, a value below 2, or a duplicate — misconfiguration fails loud, replacing the pi original's silent fall-back to defaults. `include`/`exclude` entries support `*` wildcards. Patterns are predicates over whatever tools exist at call time, not references to a registry entry, so an entry matching no currently registered tool is NOT an error — unlike `toolOrder`'s referent check, `exclude: [mcp_*]` must stay valid in a deployment that loads no MCP tools.
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit** — the suite drives a real agent loop against a scripted mock adapter (no network) and covers, at per-file 100%: counting/reset semantics (identical, different-tracked, untracked-transparent, prompt-submit reset, disposal cleanup, per-agent isolation), canonicalization (deep key-order insensitivity), threshold escalation including the `thresholds[0]` gentle-text rule, denied-call counting, no-agent transparency, wildcard escaping, config fail-loud cases, and both fold-onto-downstream paths (block and accept-with-replacement). **Snapshot** — the `repeat-tool-guard` scenario in the acp-agent example suite scripts five identical `todo_write` calls and pins both reminder tiers (gentle at the third, detailed at the fifth) as `context/message`s in the ACP transcript and the session log; the guard is loaded in the example's live tree (`cordis.yml`), inert for every other scenario (none repeats a call three times). The scenario is authored keyless (like `error-finish`/`cancel`): deterministically forcing a live model to repeat one call three times is not a stable recording. **e2e** — none: the plugin is provider-independent and deterministic, and the seam contracts it relies on are e2e-covered by their owners.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Append the reminder into the tool result** (`accept` with replaced `content` — the pi extension's mechanism, which patches result content because that is the only channel its API offers) — rejected: it makes the logged `tool/result` lie about what the tool returned, and `additionalContext` exists precisely as the separate sanctioned channel for post-execute commentary, with loop-level buffering that preserves call/result adjacency.
|
||||
- **Count in `tools/pre-execute` with a pending-reminder map** (the pi two-phase shape) — rejected: post-execute alone sees `(exec, result)` together and also fires for denied calls, so one listener with no cross-event state covers strictly more attempts with less machinery.
|
||||
- **Escalate to `block` at the highest threshold** — rejected for the initial scope: a blocked call punishes legitimate identical repeats (polling a long-running terminal, re-checking a file the agent expects to change), and an advisory reminder keeps the model in control. Revisit with evidence; the decision shape (`PostToolDecision`) already supports it.
|
||||
- **A per-deployment external hook via the CC/Codex bridges** (a `PostToolUse` script) — rejected as the answer: it works for one deployment, but a shipped, unit-tested, `cordis.yml`-configurable plugin is the harness-native form, without per-call subprocess cost.
|
||||
- **A loop-level step or repetition budget in `agent-loop`** — rejected: "plugins, not loop changes"; a hard step budget is a blunter, orthogonal control that would need its own proposal.
|
||||
- **Fuzzy/near-identical detection** (normalized paths, similar-but-not-equal arguments) — rejected: exact match after canonicalization is cheap, deterministic, and explainable to the model; similarity thresholds invite false positives and need evidence before they earn complexity.
|
||||
- **Placing the package in `core/`** — rejected: core is the product spine; a behavioral guard is an optional leaf plugin, and the `todo/` precedent is a small dedicated group per plugin family.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The reminder is advisory by design: idempotent polling patterns that repeat identical calls on purpose still receive nudges past the thresholds, and the pressure valves are config (`thresholds`, `exclude`) plus reminder text that explicitly allows finishing when enough evidence has been gathered. Each trigger costs reminder tokens on the next request; thresholds bound the frequency.
|
||||
- Chain state is in-memory only: a session resumed from persistence starts with a fresh chain, so a loop spanning a resume draws its reminders later than a live one — accepted, the guard is a heuristic nudge, not a logged invariant, and persisting counter state would buy little for real complexity.
|
||||
- When multiple post-execute producers attach context on one call, the fold concatenates under the guard's `source`; ordering between plugins follows listener registration order. The seam cannot represent mixed provenance — a limit inherited from `HookContext`, not owned by this plugin.
|
||||
- Implementing the snapshot tier surfaced a hidden assumption in the suite kit: the fixture guard equated "authored model scenario" with "override-driven". The `Scenario` table now carries an explicit `overridden` flag, and the sidecar's presence is checked BOTH ways against it (an unregistered stray sidecar would silently replace the derived script) — the suite kit is stricter than it was before this plugin existed.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Compaction does not reset chains: a compacted history changes what the model sees, but the repetition risk usually survives compaction.
|
||||
- Escalating to `block` at a high threshold is not implemented; `PostToolDecision` already supports it if evidence arrives.
|
||||
- Subagent chains stay isolated per agent; no sharing mechanism exists until a concrete case appears.
|
||||
@@ -0,0 +1,84 @@
|
||||
# RFC: The self-referential cordis toolset
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Everything in this harness is a cordis plugin, but the agent running inside that plugin runtime cannot see or touch it: it cannot enumerate the services and events around it, cannot extend itself with a new tool mid-session, and cannot compose capabilities it invents. Handing the model that power is worth exploring — a self-referential agent that inspects and modifies its own runtime — but it raises three correctness problems at once, and the design is about answering them rather than the raw "let the model run code" mechanic.
|
||||
|
||||
First, model-written registration must be validated where it happens: a malformed tool schema has to fail at registration, not when a later request tries to assemble it into a prompt. Second, model-written code has to call service APIs whose source it has never seen — guessed method signatures and, worse, guessed return-value shapes cost many steps of blind probing. Third, everything the model mounts must be fully disposable, by the model on demand and by the ordinary plugin lifecycle when the host plugin reloads, or a long session accretes orphaned listeners and tools.
|
||||
|
||||
## Decision
|
||||
|
||||
The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again.
|
||||
|
||||
The trust stance, stated once and threaded through the rest: the `node:vm` sandbox isolates the global context only — it prevents accidental global pollution, not malice — and the `ctx` a mounted plugin's `apply` receives is a whitelist façade that narrows the *surface* (framework internals withheld) but not the *privilege* of what it exposes. The verbs the façade does expose reach the real runtime: a mounted tool can shell out through `ctx.bash`, read the filesystem through `ctx.fs`, reach the network through `ctx.web`. Neither the sandbox nor the façade is a security boundary; handing the model this power is the point of the toolset. A deployment loads this plugin exactly as deliberately as it grants a bash tool — an opt-in capability in the app's `cordis.yml`, never a product default.
|
||||
|
||||
### The three tools
|
||||
|
||||
| Tool | Contract |
|
||||
|---|---|
|
||||
| `cordis_inspect` | Read-only report over the live runtime, one Markdown section per `what` value (omit `what` for all sections). Never mutates. |
|
||||
| `cordis_mount` | Evaluates `code` (the body of an async JavaScript function) in a `node:vm` sandbox; the code must `return` a cordis plugin, which is mounted as a child of the `cordis-dynamic` group fiber and tracked under a fresh id (`dyn-1`, `dyn-2`, …). |
|
||||
| `cordis_unmount` | Disposes one dynamic mount by id and returns only after disposal reaches quiescence — every registration the plugin made is unwound, not merely requested to stop. |
|
||||
|
||||
`cordis_inspect` sections: `services` (every provided ctx service and the owning fiber, non-active owners flagged), `plugins` (a flat list of every loaded plugin with its lifecycle state, from `ctx.registry` — what capabilities are loaded, deliberately not the tree shape), `tools` (what the model can call), `dynamic` (the mount table: id, name, state, provided services, awaited services), `api` (live service signatures + the type shapes they reference, from the generated catalog), and `events` (harness events with dispatch mode and signature). The model-facing tool descriptions carry the operational rules the model needs at call time; [the generated tool catalog](../../../tool-catalog.md) is their exhaustive rendering.
|
||||
|
||||
### Sandbox semantics
|
||||
|
||||
Mount code runs via `vm.createContext` + `runInContext`, wrapped as the body of an async function under a per-mount filename (`cordis-mount-<id>.js`). The vm gives the code a fresh realm: writes to `globalThis` stay inside the sandbox, and no Node API is handed in — capability access is *steered* toward the cordis services (`ctx.fs` for files, `ctx.web` for HTTP, `ctx.bash` for processes, the `ctx.timer` helpers for timing) rather than Node built-ins, so a well-behaved mount stays inspectable through `cordis_inspect` and disposable with its fiber. This is steering, not containment: consistent with the trust stance above, the small global surface keeps *honest* code on the cordis services but is not a security boundary — the host-realm helpers it exposes (`harness`, `console`, `btoa`) are reachable functions, so mount code that goes looking (through such a helper's `.constructor`, say) can still reach the host realm and Node itself, which is accepted because the `ctx` a mount ultimately receives is fully privileged anyway. The `vmTimeoutMs` config bounds only the synchronous portion of evaluation; an async body escapes the bound (also acceptable under the trust stance).
|
||||
|
||||
Sandbox globals are deliberately small: a tagged write-through `console` (`[cordis:<id>] …` on the host stdout/stderr, so a listener that fires long after the mount call still lands somewhere the user sees), the `harness.defineTool` / `harness.registerTool` registration pair, the encoding primitives fresh vm contexts lack (`btoa`/`atob` as host closures over `Buffer` — a sanctioned exception, `Buffer` itself is never exposed — plus `TextEncoder`/`TextDecoder`), and callable traps over the withheld Node APIs (`require`, `setTimeout`/`setInterval`/`setImmediate`/`clearTimeout`/`clearInterval`, `fetch`) that throw a redirect naming the cordis alternative. Only function-shaped globals are trapped; `process` and `Buffer` stay `undefined` so a `typeof` feature probe stays inert rather than detonating a throwing accessor.
|
||||
|
||||
Three boundary mechanisms make model-written code behave correctly across the realm seam. **Dual-realm `instanceof`**: most objects sandbox code touches are host-realm (tool `args`, event payloads, service returns), so a plain `x instanceof Array` in the vm would silently be false — a per-sandbox prelude gives the vm realm's own constructors a `Symbol.hasInstance` that checks both the vm constructor and its host counterpart, patching only vm-realm globals. **Realm normalization of tool results**: objects built inside the vm carry the vm realm's `Object.prototype`, which the session log's append-time plainness check (`isJsonValue` in `dsh-session`, a prototype-identity comparison) rejects, so the sandbox's `harness.defineTool` JSON round-trips every `execute` return into the host realm — which also projects it onto exactly what the log durably stores — and then shape-checks it against the two `ToolExecuteReturn` forms, so a JSON-valid but wrong-shape return (a bare string, `{ content: 'ok' }`) fails that one call with a teaching error instead of entering the log as corrupt tool-result content. **A whitelist context façade**: the `ctx` a mounted plugin's `apply` receives is NOT the real context nor a pass-through proxy over it — it is a façade exposing only what a mount legitimately needs (`tools.register` marker-guarded, a read-only `tools.get`/`schemas`, `on`/`once`, `provide`, the timer helpers, and the services the plugin DECLARED in `inject`), with every framework-plumbing member (`root`, `parent`, `fiber`, `reflect`, `registry`, `extend`, `isolate`, `intercept`, `plugin`, `set`, `mixin`, …) denied with a teaching error. This closes an escape *class* rather than a single hole: a proxy that merely special-cased `ctx.tools` still handed back the raw context through `ctx.root`, `ctx.extend()`, or a service instance's `.ctx`, and mount code could then `ctx.root.tools.register({…})` to bypass the marker check and realm normalization — a raw vm-realm result then errors a real agent turn at the plainness check. The façade has no context-valued member to reach, and the one indirect leak (an injected-service method returning a `Context`) is rejected on the way back to sandbox code. Two narrower rules complete the surface. First, **service access requires an `inject` declaration**: reaching a service the mount did not declare is refused even when a global provider is live — otherwise a mount could depend on a provider cordis never sees, and unmounting that provider would neither park the consumer nor unwind the tools it registered, leaving a model-visible tool that fails only at execution time. Because the read is gated on the declaration, cross-mount `provide`/`inject` keeps its lifecycle guarantees (the plugin's own `inject` and the fiber's pending/active gating drive activation and unload); only the `apply`-time `ctx` surface is narrowed. Second, **`ctx.tools.get` returns a read-only schema view** (name/description/parameters), never the live `ToolDefinition` — handing back the definition would expose its `execute`, letting mount code call another tool directly and bypass `ToolRegistry.execute` and its pre/post-execute hooks and accounting; a mount that wants to invoke a tool must go through the registry, and one that wants to introspect gets the same view `schemas()` returns.
|
||||
|
||||
Boundary errors are written around the mistakes models actually make (see [Consequences](#consequences) for how each was found), and the boundary normalizes rather than lectures wherever the input has exactly one meaning: schema `parameters` accept the JSON-Schema dialect models write by strong prior — the `{ type: 'object', properties, required: […] }` wrapper unwraps to the SchemaSpec DSL (the `required` array becoming per-property flags, at any nesting level), `type: 'integer'` maps to `number`, and `required: false` reads as optional — while genuinely meaningless input is rejected with the vocabulary enumerated (an unknown type lists the five valid ones; a non-boolean `required` names the rule). The remaining teaching errors: an unbalanced `});` closing gets the vm's offending source line plus a "code is a function body" reminder; TypeScript syntax gets the remove-annotations fix (detected on the failing line only, so an ` as ` inside a description string does not misfire); a forgotten `return` gets the two valid plugin forms; a Node built-in call gets the redirect to its cordis service; a tool-name collision on re-mount gets the unmount-first-then-remount recipe.
|
||||
|
||||
### The dynamic group and mount lifecycle
|
||||
|
||||
Every dynamic mount is a child of a single `cordis-dynamic` group fiber, itself a child of the `tool-cordis` plugin's fiber. The group exists so the mounts form one subtree: they are disposed as a unit, and disposing `tool-cordis` (HMR reload, config unload) cascades over every mount through the ordinary parent→child fiber lifecycle — no bespoke cleanup. Mounting settles before it reports: the returned fiber is `await()`ed, and a startup error (a throwing `apply`, a duplicate tool name, a duplicate service) disposes the fiber and surfaces as the tool error, so a failed mount never lingers. A settled fiber that is not active is a legal pending mount — cordis semantics for unsatisfied `inject` — kept mounted and reported with what it waits for. Everything the plugin registers is an effect on its fiber, so `cordis_unmount` is nothing but an awaited `fiber.dispose()`.
|
||||
|
||||
### Cross-mount composition via provide/inject
|
||||
|
||||
Mounts relate to each other through ordinary cordis service semantics, with their ids as the lifecycle handles: mount A calls `ctx.provide('foo', value)`, mount B declares `inject: ['foo']` and activates the moment `foo` exists; mounted first, B stays pending and names the missing service; unmounting A sends B back to pending (its registrations unwound) and a later re-provide re-runs B's `apply` through a fresh sandbox façade; a duplicate provide fails loud with the owning fiber named. One realm caveat: a service value provided by a mount is a vm-realm object — method calls on it work from anywhere, but consumers must not assume host prototypes on it.
|
||||
|
||||
### The generated API catalog
|
||||
|
||||
`cordis_inspect what:"api"` and `what:"events"` answer from a machine-readable catalog generated at build time, never a hand-maintained table that would drift from the JSDoc it paraphrases. [`scripts/gen-cordis-api.ts`](../../../../scripts/gen-cordis-api.ts) reuses `collectServices` / `collectEvents` from [`scripts/gen-cordis-catalog.ts`](../../../../scripts/gen-cordis-catalog.ts) — the same AST walk that generates [the cordis service catalog](../../../cordis-catalog/services.md) and [events catalog](../../../cordis-catalog/events.md) — and emits `packages/cordis/tool-cordis/src/api-catalog.ts`, a committed, banner-commented data module. The artifact carries, per service, its key + one-line summary + raw method signatures; per event, name + `@mode` + signature + summary; the comment-stripped declarations of every exported type the service signatures reference (transitive closure — so a consumer sees that a bash run's `stdout` is `{ text, truncated }`, not a string); plus the curated inherited `ctx` surface shared with the cordis catalog generator. A type name declared in more than one package (each plugin's `Config`) is dropped as ambiguous, and an oversized declaration is truncated with a marker.
|
||||
|
||||
Freshness is gated like every generated artifact: `pnpm run verify-cordis-api` (in `doc-sync`) regenerates in memory and fails on any diff, so a JSDoc edit that changes a public signature cannot ship without regenerating the catalog the model reads. At runtime the inspect tool intersects the catalog with the live runtime rather than dumping it: live catalogued services render summary + signatures, live services without a catalog entry (mount-provided ones) render name + owning fiber, catalogued services with no live provider are listed tersely, and the referenced type shapes follow.
|
||||
|
||||
### Configuration, rendering, and observability
|
||||
|
||||
The plugin exposes one config field, validated by schemastery and documented in [the config catalog](../../../config-catalog.md): `vmTimeoutMs` (default 5000), the millisecond bound on the synchronous portion of mount-code evaluation. Tool names, the `cordis-dynamic` group name, and the `dyn-` id prefix are structural vocabulary and stay fixed. All three tools render as `generic` cards per [the tool cookbook](../../../cookbook/adding-a-tool.md) (`cordis_inspect` a `read`, `cordis_mount` an `execute` carrying the code as `rawInput`, `cordis_unmount` a `delete`), with no `presentResult` overrides.
|
||||
|
||||
Model-visible ⟺ logged holds with no new session event type: a mount or unmount is visible only through its own `tool/call` / `tool/result` pair, which the loop logs, and the changed tool set a mount induces is logged by the request-header delta the loop already emits when schemas change between steps. There is deliberately no `cordis/mount` provenance event — it would duplicate what the tool-call pair records. Dynamic mounts are process-lifetime, not session state: resuming a persisted session rehydrates the conversation but does not re-mount plugins.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A structured per-capability registration tool instead of `cordis_mount`.** The most tempting alternative is a `cordis_register_tool` with explicit `name` / `description` / `parameters` / `code` fields (and siblings `cordis_register_listener`, `cordis_register_service`, …) rather than a single "mount a plugin" primitive. It was rejected because its one real win — no plugin boilerplate for the single commonest case — does not pay for its costs, while a single mount primitive answers every capability at once.
|
||||
|
||||
| Dimension | Structured per-capability tools | Single `cordis_mount` |
|
||||
|---|---|---|
|
||||
| Schema correctness | `parameters` is still a model-written JSON object needing SchemaSpec validation, merely one step earlier | The same validation runs at the sandbox boundary, with the same instructive errors |
|
||||
| The code field | An `execute` body is still model-written JS in a vm; the realm and service-call correctness problems are unchanged | One sandbox, one normalization path, one guarded registration |
|
||||
| Capability coverage | Tools only; listeners, services, `inject` relations each need another structured tool — a surface that grows without bound | One vocabulary (a cordis plugin) covers every effect, present and future |
|
||||
| Cross-mount composition | Not expressible in a tool-registration payload | Native `provide`/`inject`, ordinary cordis semantics |
|
||||
| Inspectability | Registers something the plugin list cannot show as a plugin | What the model mounts is exactly what `cordis_inspect` renders |
|
||||
| Model ergonomics | Wins for the single most common case (no plugin boilerplate) | Mitigated by the canonical recipe in the mount description plus boundary errors that teach the fix |
|
||||
|
||||
The correctness investment therefore goes where it pays for every capability at once: the generated API catalog surfaced through `cordis_inspect`, and sandbox-boundary validation whose error messages teach the correct call. A structured registration tool remains addable later as sugar that synthesizes mount code; nothing here forecloses it.
|
||||
|
||||
**A hand-maintained service/event reference in the tool.** The first cut of the inspect tool carried a hand-written table of service method signatures. It was replaced by the generated `api-catalog.ts` because a hand table drifts from the JSDoc the moment a signature changes and nothing gates the drift, whereas the generated artifact is freshness-checked against the same AST the docs use.
|
||||
|
||||
**A new `cordis/mount` session event.** A durable provenance event recording each mount (source, name) has clear precedent (`hook/invoked`, `compact/start`). It was declined for v1: mount and unmount are already visible as `tool/call` / `tool/result` pairs and the tool-set change is already logged as a request-header delta, so a dedicated event would only duplicate the record. It remains addable if an audit use case needs mount provenance separable from the tool call.
|
||||
|
||||
**A hardened / capability-restricted sandbox.** Trapping Node built-ins and handing mount code a whitelist façade rather than the raw context might suggest an intent to sandbox for safety. It is explicitly not that: the traps and the façade narrow the *surface* mount code sees — steering it onto cordis services and away from leak-prone Node built-ins and framework internals — for correctness and to close the unguarded-context escape, but the capabilities the façade exposes (`ctx.bash`, `ctx.fs`, `ctx.web`) reach the real runtime, so it is not a security boundary. A real one (separate process, permission prompts) was out of scope for a dev/opt-in toolset and would fight the entire point — handing the model the live runtime.
|
||||
|
||||
## Consequences
|
||||
|
||||
The toolset is a deliberate opt-in with a fully-privileged `ctx`, so a deployment adopts it as consciously as a bash tool. Several facts follow that the tool descriptions warn the model about directly: a waterfall listener (e.g. `tools/pre-execute`) that returns without calling `next()` vetoes the chain, so a mounted listener can lobotomize the agent's own tool dispatch ([waterfall semantics](../../../cordis-primer.md#cordis-waterfall-semantics)); mount code runs inside a tool call of the current turn, so awaiting anything that resolves only after the turn deadlocks; `vmTimeoutMs` bounds synchronous evaluation only; and mounts do not survive session resume.
|
||||
|
||||
The instructive boundary errors were not guessed — they were written against live self-design sessions in which a real model was asked to build itself coding tools. Those sessions surfaced the failure modes now mitigated: the model closed a returned plugin object with `});` and got only a bare `Unexpected token ')'` it retried blind; it hit a false-positive "this is TypeScript" hint because a description string contained the word "as"; it guessed a bash run's `stdout` was a string and burned six steps building throwaway debug tools to discover it is `{ text, truncated }`; and it wrote tool schemas in the JSON-Schema dialect (`type: 'integer'`, `required: false`, then the full wrapper) three rejections in a row — the rejection text itself pushing it from a nearly-correct DSL attempt back to raw JSON Schema. The fixes — source-line-plus-caret parse errors, line-scoped TypeScript detection, the type-shape closure in the API catalog, the redirect traps, and schema-dialect normalization in place of rejection — cut later sessions from dozens of tool calls with repeated errors to a first-try success on every capability, including a model that hit a Node-`setTimeout` trap and self-corrected to `inject: ['timer']` in one step.
|
||||
|
||||
Coverage is named per tier: package unit specs drive the three tools through a real `ToolRegistry` on a real fiber tree (the mount success/failure family, vm isolation, dual-realm `instanceof`, realm normalization against the real `isJsonValue`, the SchemaSpec and raw-registration rejections, the Node-API traps, the cross-mount provide/inject matrix, catalog-backed `api`/`events` rendering, config validation, presenters, quiescent unmount, and the HMR cascade), a `MockAdapter` loop test proves a tool mounted in one step is dispatchable in the next, and the example carries a keyless Loader smoke plus a with-key smoke that world-verifies a live model mounting a listener, building its own tool, and composing two mounts. No snapshot scenario is added: the toolset ships in no ACP-served app, so it changes no editor-facing transcript, and its presenters are unit-tested pure functions — adding it to the ACP example solely for a golden would rewrite the pinned request-header tool set of every recorded scenario.
|
||||
@@ -14,7 +14,7 @@ Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks
|
||||
- ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded.
|
||||
- Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion.
|
||||
- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations.
|
||||
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a demo smoke test driving the echo-agent end to end.
|
||||
- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus a demo smoke test driving the echo-agent end to end.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog/tools.md`, and a freshness gate so it cannot drift.
|
||||
A reader — a plugin author, a prompt engineer, someone auditing what the agent can do — has no single place that lists the model-facing tools the harness ships. The `name` / `description` / JSON-Schema `parameters` a tool contributes are what the model actually receives (via `ctx.systemPrompt.tools()` off `ctx.tools.schemas()`), but they are scattered across each `defineTool` call in each `packages/*/tool-*` package, buried in string concatenation and runtime spreads. The cordis [events](../../../cordis-catalog/events.md) & [services](../../../cordis-catalog/services.md) catalogs ([their RFC](2026-06-20-generated-cordis-catalog.md)) document the *wiring* a plugin works against and the [core-data-structures catalog](../../../core-data-structures/core.md) documents the *vocabulary* those signatures move — but neither documents the *tools* the agent is offered. This RFC adds that third reference surface, `docs/tool-catalog.md`, and a freshness gate so it cannot drift.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The repo already had several high-trust documentation surfaces, each on a different axis: [module-graph.md](../../../module-graph.md) is generated from package `peerDependencies`, the generated [Cordis events](../../../cordis-catalog/events.md) and [services](../../../cordis-catalog/services.md) catalogs are generated from Cordis `Events` and `Context` declarations, [tool-catalog/tools.md](../../../tool-catalog/tools.md) is generated by booting shipped tool plugins, and [core-data-structures/](../../../core-data-structures/core.md) uses `ts type-equiv` blocks to keep pasted type definitions synchronized with source.
|
||||
The repo already had several high-trust documentation surfaces, each on a different axis: [module-graph.md](../../../module-graph.md) is generated from package `peerDependencies`, the generated [Cordis events](../../../cordis-catalog/events.md) and [services](../../../cordis-catalog/services.md) catalogs are generated from Cordis `Events` and `Context` declarations, [tool-catalog.md](../../../tool-catalog.md) is generated by booting shipped tool plugins, and [core-data-structures/](../../../core-data-structures/core.md) uses `ts type-equiv` blocks to keep pasted type definitions synchronized with source.
|
||||
|
||||
Those references are accurate, but they are mostly catalogs. A maintainer still has to synthesize the relationships: which packages form a capability seam, which app bundles a concrete spine, which event is durable vs live, where a hook or policy plugin can intercept work, and which model-facing tool depends on which service. An SDK user has the same problem from another angle: "Which package do I install or load for the behavior I want, and which event/service/tool do I extend?"
|
||||
|
||||
@@ -31,7 +31,7 @@ The first index links ten relationship surfaces. Package topology and tool-packa
|
||||
| Graph | Maintenance mode | Source of truth |
|
||||
|---|---|---|
|
||||
| [module dependency graph](../../../module-graph.md) | generated | `packages/*/*/package.json` peer dependencies plus package group paths |
|
||||
| [tool schema catalog and package map](../../../tool-catalog/tools.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata |
|
||||
| [tool schema catalog and package map](../../../tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata |
|
||||
| [capability seams and core services](../../../capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` |
|
||||
| [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion |
|
||||
| [coding-agent app composition](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` plugin list plus curated app/bundle expansion |
|
||||
|
||||
@@ -8,7 +8,7 @@ The session event log is the harness's on-disk contract: every `SessionEventMap`
|
||||
|
||||
## Decision
|
||||
|
||||
Generate `docs/persistence-catalog/log-events.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools).
|
||||
Generate `docs/persistence-catalog.md` from source, with a freshness gate, as the fourth reference surface: the *records* a persisted session log can contain, complementing the cordis catalog (wiring), core-data-structures (vocabulary), and the tool catalog (tools).
|
||||
|
||||
`scripts/gen-persistence-catalog.ts` is a pure TypeScript-AST pass, like `gen-cordis-catalog.ts` — log events ARE statically knowable: every member is a string-literal-named property with a static type annotation, so the AST is the whole truth. The walk collects every `interface SessionEventMap` declaration under `packages/*/*/src` — the owning top-level interface and every `declare module '@deepseek-ai/dsh-session'` merge — so a brand-new event, core or merged, appears in the next regenerate and an un-regenerated file fails `--check` (`verify-persistence-catalog`, a `doc-sync` member, so pre-push and CI both run it). Each entry renders the member's JSDoc prose, its payload (printed through the TypeScript printer, so a newline-separated multi-line type literal still yields a valid one-line fragment), a surface badge, cross-links into core-data-structures, and the declaration's source pointer, grouped by scope.
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# RFC: Export-surface JSDoc gate
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The [cordis JSDoc completeness gate](2026-07-04-cordis-jsdoc-completeness-gate.md) made undocumented parameters and results impossible on the cordis surface — `interface Events` members and `ctx.<key>` service classes — but that surface is a fraction of what a plugin author imports. The AGENTS.md rule "every export (and non-obvious method) has a JSDoc explaining semantics" stayed prose-checkable only by review everywhere else, and nothing at all asked for `@param`/`@returns` on ordinary exported functions. A survey at adoption found 203 under-documented module-level exports across 34 packages: seam-adjacent helpers (`runBash`, `readForEdit`, `htmlToMarkdown`), format codecs, whole undocumented interfaces and type aliases — exactly the names an IDE consumer hovers.
|
||||
|
||||
## Decision
|
||||
|
||||
A new gate, `scripts/verify-export-jsdoc.ts` (`pnpm run verify-export-jsdoc`, wired into `doc-sync` beside `verify-cordis-catalog`), walks every module-level exported name under each `packages/<group>/<pkg>/src/` tree. The parsing and check helpers moved from `gen-cordis-catalog.ts` into a shared `scripts/jsdoc.ts`, so "documented" means the same thing on both surfaces: description prose ends at the first block tag, every checkable parameter needs a non-empty `@param`, a non-void ANNOTATED return needs a non-empty `@returns`, a stale `@param` errors, and violations aggregate into one report.
|
||||
|
||||
The contract by declaration kind:
|
||||
|
||||
- Every exported name needs JSDoc with non-empty description prose.
|
||||
- Function-like exports (function declarations; consts with function initializers or an INLINE callable annotation; non-identifier function default exports) follow the full function contract, with wrapper expressions (parentheses, `as`/`satisfies` casts, non-null assertions) peeled before classifying. A const whose declarator is annotated with a NAMED type (`export const f: Handler = …`) defers the signature contract to that type's own declaration and `@returns` stays optional; an inline `(x: T) => U` annotation or single-call-signature literal is the surface signature itself and gets the full contract, and a literal mixing call/construct signatures with anything else is refused outright (no single signature to hold the tags against — extract a named type).
|
||||
- Exported classes need class-level prose; public methods (statics included — reachable on the exported name) follow the function contract; public properties and accessors need prose (a get/set pair is covered by the getter). Overload implementations are exempt — the signatures carry the docs.
|
||||
- Exported interfaces, type aliases, and enums need prose on the declaration; member-level enforcement is deliberately deferred (the highest-value member surface — seam service classes — is already under the cordis gate).
|
||||
- Exported namespaces recurse (inside an ambient `declare` namespace every member exports implicitly); the namespace itself needs prose only when it does not merge with a documented same-name declaration (the Config-namespace idiom documents the plugin once).
|
||||
- `declare module` / `declare global` bodies and `export … from` re-export statements are skipped: an augmentation is not an export of the package, and a re-exported definition is checked where it is defined. An `export import X = N.member` alias documents ITSELF — its target may be a non-exported namespace member no walk visits — and only prose-only target kinds are gate-supported: a callable, class, or namespace target carries signature/member contracts the alias prose cannot hold, so the gate refuses it and demands the declaration be exported directly.
|
||||
- Everything else fails CLOSED: `export =` is refused outright, parameters the base never names keep their `@param` duty even as binding patterns, and an exported statement kind the dispatch does not recognize is itself a violation — no export form can pass unchecked by omission.
|
||||
|
||||
Three exemption families keep the gate from demanding boilerplate, in the spirit of the cordis gate's `this`/`next` exemptions (documenting an exempt name anyway is allowed; only absence goes unchecked):
|
||||
|
||||
- **Heritage members.** A class member whose name exists on an `extends`/`implements` heritage type is exempt: the seam declaration is the doc's one home, and the IDE inherits it on hover — re-documenting every `LocalBashExecutor.run` invites drift. The exemption stops where the override grows surface the base never documented: a protected-only base member does not exempt a public override, parameters the base never names keep their `@param` duty (an underscore-prefixed rename of a base parameter — the deliberately-unused marker — is the same parameter), and a concrete result above a void base return keeps its `@returns` duty (an unannotated override's inferred return is classified by the checker, so a faithful void override needs no boilerplate annotation). Heritage lookups and that one return classification are the walk's only TYPE CHECKER questions (heritage types live across package boundaries, resolved through the repo `paths` map); everything else stays pure AST, and the annotated-return requirement is kept for symmetry with the cordis gate (it bound nothing at adoption — every exported function was already annotated).
|
||||
- **Plugin-protocol slots.** Top-level `name` / `inject` / `reusable` / `Config` consts and the `apply` entry, plus the same slots as statics on a plugin class, are framework protocol: their shape is fixed by cordis, and the module doc comment plus the `interface Config` carry the plugin's real semantics.
|
||||
- **Constructors**, mirroring the cordis gate: plugin classes are framework-constructed, and the class doc owns the story.
|
||||
|
||||
`collectExportJsdocViolations()` returns the violation list (the CLI exits 1 on non-empty) so the negative-path tests in `packages/core/agent/tests/verify-export-jsdoc.spec.ts` assert on findings directly, driving fixture packages through every rejection and every exemption.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **eslint-plugin-jsdoc** (`require-jsdoc`/`require-param`/`require-returns`) — covers the mechanical core but cannot express the repo's contract: the heritage-member exemption needs cross-package type resolution, the protocol-slot and namespace-merge idioms are cordis-specific, and the completeness semantics (prose-above-tags, stale-tag errors, aggregate reporting) already have one home in `scripts/jsdoc.ts` shared with the catalog generator. Two subtly different definitions of "documented" is the failure mode this repo's one-home rule exists to prevent.
|
||||
- **Extending `gen-cordis-catalog.ts`** — the catalog generator renders a curated surface and gates its freshness; a repo-wide walk has no catalog to render. Sharing the helpers while keeping the walks separate keeps each gate's scope legible.
|
||||
- **Enforcing interface/type-alias member docs** — deferred: it would multiply the checked surface for members that are largely self-describing fields, while the seam classes carrying the load-bearing member contracts are already gated. Revisit if member-doc drift shows up in review.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green.
|
||||
- Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them.
|
||||
- Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements.
|
||||
- The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets.
|
||||
- The protocol-slot names are reserved by convention at module top level; a non-protocol export coincidentally named `apply` or `Config` would go unchecked — accepted, documented here.
|
||||
@@ -0,0 +1,38 @@
|
||||
# RFC: Generated plugin config catalog
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The config surface — the exact set of fields a `cordis.yml` entry's `config:` block can set for each plugin, with types, defaults, and semantics — had no reference page. A deployment author assembling a config tree had to open every plugin's source (or trust its README) to learn what is settable. The per-package README `## Config` sections cover parts of it by hand, in formats that diverged package-by-package (a key/default table here, an annotated YAML snippet there) and with no gate tying them to source. Nothing enumerated which packages are loadable at all — plugin vs abstract seam vs plain library — and nothing verified that the runtime schemastery schema and the documented `Config` interface agree, so a schema-validated field could exist with no documentation anywhere.
|
||||
|
||||
## Decision
|
||||
|
||||
Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. The paste is the plugin's full declared config type: a field the runtime schema deliberately excludes is a runtime-only seam, marked as such by its own JSDoc, not a `cordis.yml`-settable knob. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited.
|
||||
|
||||
Pure AST generation is correct here for the same reason it is for the events/services catalog and NOT for the tool catalog: a config type is a static declaration and every schemastery schema in the repo is a static `z.object`/`z.intersect` literal, so the source is the whole truth — nothing about the config surface is runtime-composed.
|
||||
|
||||
Specific choices:
|
||||
|
||||
- **The config type is the second-parameter type.** What the catalog documents is the declared type of `apply(ctx, config)` / the service constructor's `(ctx, config)` — the value cordis actually passes — not a `Config` export located by naming convention. This is what makes the walk total: it works for interfaces named `AcpConfig` or `BasicCompactConfig`, for types declared in a sibling file, and for plugins with no validating schema at all.
|
||||
- **Classification is total.** Every `packages/<group>/<pkg>` entry resolves, mirroring the Loader's `unwrapExports` (`exports.default ?? exports`), to a configurable plugin, a config-free plugin, an abstract seam class, or a library — each rendered in its own section — and an unclassifiable entry hard-errors. A new package cannot be silently undocumented.
|
||||
- **Per-field JSDoc is enforced.** Every property of a pasted declaration (nested type literals included) needs non-empty JSDoc prose, or generation fails. The paste IS the documentation, so this is the same forcing function the events catalog applies via `@mode`: thin source docs fail the gate rather than yielding a thin catalog.
|
||||
- **The schema is cross-checked, one-directionally, nested keys included.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys and their nested object/array compositions as key paths (`agents[].id`), chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated key path must be locatable on the declared config type, resolving package-local and workspace-imported types (re-export chains included), intersections, unions, utility wrappers, and indexed access. So the paste cannot hide a loader-accepted field, top-level or nested. The check is presence-only and fails loud only on a definite miss: a path crossing a type the walk cannot enumerate (an external package's type) is skipped rather than mis-reported, and dynamic-key shapes (`z.dict`) or union alternatives contribute no nested paths. The reverse direction is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`).
|
||||
- **A dedicated fence.** Pasted declarations use a ` ```ts config-catalog ` info string that `doc-typecheck` skips (a lone declaration referencing imported types is not standalone-compilable), excluded from the opt-out ratio — the same treatment the `cordis-catalog` and `persistence-catalog` fences get.
|
||||
- **A single file at `docs/config-catalog.md`**, not a one-file directory: the page serves one audience (the `cordis.yml` author) with one axis, unlike `cordis-catalog/`, which holds two sibling pages.
|
||||
|
||||
The package README `## Config` sections stay. The overlap is accepted deliberately: the README is the curated per-package contract (config semantics in deployment context, alongside limitations and extension points), the catalog is the exhaustive generated enumeration. Because the catalog is generated, a disagreement between the two indicts the README, and the fix is a README edit — the catalog cannot drift.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Synthesized per-field rendering** — a bullet list, table, or annotated-YAML snippet per field, assembled from parsed JSDoc plus schema metadata. Rejected for the verbatim paste: the interface with its JSDoc is already the authored contract in its authored form, and a synthesizing renderer re-formats prose it does not own, adding a rendering layer that can misrepresent it.
|
||||
- **Runtime boot + schema introspection, as the tool catalog does** — rejected: nothing here is runtime-composed, and the schema alone under-documents the surface (prose-documented defaults, runtime-only fields, plugins with no schema at all). Booting would add fragility without adding truth.
|
||||
- **Two-directional schema/interface equality** — rejected for the subset check: the declared type legitimately carries members the schema refuses to accept from config (runtime-only seams).
|
||||
- **Retiring the README `## Config` sections in the same change** — rejected: the accepted duplication keeps the per-package contract readable in place, and a sweep would have to fold each README's extra facts into field JSDoc first — separable work the catalog does not depend on.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in pre-push and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright.
|
||||
- Config prose now has a forcing function at the declaration: writing a new config field means writing its JSDoc, which becomes the catalog entry verbatim.
|
||||
- The generator hard-errors on shapes it cannot walk statically — an aliased package-local config import, a schema built by anything other than `object`/`intersect` composition, an unlisted global type name. Introducing such a shape includes teaching the generator (or the shape stays out of the repo), which is the point: the catalog stays the whole truth.
|
||||
- `gen-cordis-catalog.ts` exports its JSDoc/pointer helpers and `LINK_MAP` for reuse, so the two catalogs cross-link types identically and a link-map addition serves both.
|
||||
@@ -0,0 +1,37 @@
|
||||
# RFC: Raise the Node LTS engine floor to 22.19
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The Node 22 branch of the root `engines.node` range is a contract for the installed workspace, not only for the runtime APIs the harness source calls directly. It must be no lower than package `engines.node` declarations for dependencies the workspace installs on that branch; otherwise `pnpm install --engine-strict` fails at an advertised LTS version, and non-strict installs run outside a dependency's supported runtime.
|
||||
|
||||
## Decision
|
||||
|
||||
Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
|
||||
|
||||
Two Node features gate the source runtime:
|
||||
|
||||
- **`node:sqlite`** — `packages/session-persistence/session-persistence-sqlite` does a top-level `import { DatabaseSync } from 'node:sqlite'`. The module dropped its `--experimental-sqlite` flag requirement at **22.13** (LTS) and **23.4** (Current); before those, importing it throws at load.
|
||||
- **Native TypeScript type-stripping** — the `packages/ui/stdio-agent/tests/built-bin.e2e.ts` smoke boots the published `lib/bin.js` under plain `node` (no tsx) and loads the example's `.ts` plugins (`mock-llm.ts`, `echo-tool.ts`). Type-stripping is the default from **22.18** (LTS) and **23.6** (Current); before those it needs `--experimental-strip-types`.
|
||||
|
||||
Those source features clear on the 22.x line at **22.18**, but the installed Pi adapter dependency raises the advertised LTS floor. `@deepseek-ai/dsh-llm-pi-ai` depends on `@earendil-works/pi-ai@0.79.3`, whose package declares `engines.node >=22.19.0`, so the LTS floor is **22.19**. The 24.x branch remains `>=24.0.0`. The disjoint range excludes Node 23 entirely: Node 23.0–23.5 still has at least one flagged source feature, and the 23 line is non-LTS/EOL, so advertising `>=23.6` would add a dead release line and a CI leg no deployment should use.
|
||||
|
||||
`@types/node` remains pinned to the 22.x line (`^22.20.0`) to match the LTS support line: reaching for a Node 23+/24+/25+ API fails `tsc` on every machine and in the typecheck gate, rather than compiling clean and surviving to a runtime failure only a floor matrix leg could catch. The whole tree typechecks clean against the Node 22 type surface today, so the pin costs nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
|
||||
- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line.
|
||||
- The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
|
||||
- A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this RFC in the same change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep `^22.18.0 || >=24.0.0`.** Rejected: it advertises an LTS version lower than the Pi adapter dependency floor. `@earendil-works/pi-ai@0.79.3` requires `>=22.19.0`.
|
||||
- **Downgrade or pin `@earendil-works/pi-ai` to preserve the 22.18 advertised range.** Rejected: the current Pi adapter dependency is part of the intended workspace, and 22.19 is still inside the Node 22 LTS line.
|
||||
- **Floor `>=22.13` (the `node:sqlite` boundary) plus `--experimental-strip-types` in the built-bin smoke on 22.13–22.17.** Rejected: it adds a version-conditional test flag for one narrow range and dresses up an experimental-flag dependency as first-class support. The Pi adapter dependency already requires a higher LTS floor.
|
||||
- **Open-ended `>=22.19`.** Rejected: it advertises support for Node 23.0–23.5, where `node:sqlite` (until 23.4) or type-stripping (until 23.6) is still flagged.
|
||||
- **Include Node 23.6+ (`^22.19.0 || >=23.6.0`).** Rejected: 23.6+ does run both source features unflagged, but Node 23 is end-of-life; advertising a dead release line adds a range term and a CI leg for a runtime no deployment should use.
|
||||
- **Matrix `[22, 24, 26]` instead of pinning `22.19`.** Rejected: floating major-version entries drift upward over time and silently stop exercising the declared LTS floor.
|
||||
- **Keep `@types/node` ahead of the floor (`^25`).** Rejected: types ahead of the runtime floor let a Node 24/25-only API compile clean and fail only at runtime on 22.x. Pinning `@types/node` to the 22.x line turns that into a compile error everywhere.
|
||||
@@ -0,0 +1,39 @@
|
||||
# RFC: Parallel GitHub CI gates
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
|
||||
The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and built-bin smoke tests need the built `lib/` outputs, while most gates only need source and dependencies. A blind fan-out either races those artifact consumers before `pnpm run build` has emitted declarations and bundles, or repeats the build in every artifact-dependent job.
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The Node 26 compatibility job installs once and runs `pnpm run check:node-compat`.
|
||||
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers; the Node 26 compatibility job owns the TypeScript typecheck. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log.
|
||||
|
||||
Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane.
|
||||
|
||||
Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report.
|
||||
|
||||
Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=14` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates.
|
||||
- **Run every gate as a separate GitHub job** - maximizes GitHub-visible fan-out, but it creates too many checks and pays repeated setup/install overhead for gates whose runtime is shorter than the runner preparation.
|
||||
- **Upload build artifacts to artifact-dependent jobs** - preserves correctness across many jobs, but it adds artifact upload/download time and keeps the workflow wide when the artifact consumers can run behind a local dependency in the primary job.
|
||||
- **Run `typecheck` and `build` concurrently** - exposes more work to the scheduler, but both commands invoke `tsc -b`; sharing incremental build state between them is a needless race for a small wall-clock gain.
|
||||
- **Use unbounded real-API e2e parallelism** - rejected because the suite includes many live model/tool scenarios; the worker pool needs an explicit `DSH_E2E_MAX_WORKERS` cap so CI and local runs can fan out without hiding quota or resource problems behind flaky rate-limit failures.
|
||||
|
||||
## Consequences
|
||||
|
||||
PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside each broad job. That keeps runner setup overhead bounded and the Actions UI compact, at the cost of losing one status check per leaf gate.
|
||||
|
||||
The broad-lane split repeats checkout, setup, and install more often than a single primary job. That setup cost is intentional: on GitHub's hosted runner, running lint, coverage, and snapshot replay in one process pool oversubscribes CPU badly enough that the single-job critical path is longer than the repeated setup.
|
||||
|
||||
The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy.
|
||||
|
||||
The Node 26 signal is narrower than the primary Node 24 signal. It proves the source graph on the newer runtime without doubling documentation, coverage, publication, snapshot, and smoke checks whose failures are not expected to vary by Node minor version.
|
||||
@@ -0,0 +1,40 @@
|
||||
# RFC: Parallel pre-push gates
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent.
|
||||
|
||||
Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift.
|
||||
|
||||
`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses.
|
||||
|
||||
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks concurrently and prints one timing/output block per gate.
|
||||
|
||||
The build gate makes the hook self-contained from a clean worktree. `publint` and `verify-node-next-types` wait for that build output, while source-only gates continue in parallel.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The aggregate package scripts remain the source of truth for ad hoc local runs. The scheduler is a parallel execution plan over their member gates, not a replacement vocabulary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule.
|
||||
- **Declare one lefthook job per leaf gate** - exposes parallelism through lefthook's native job model, but it makes the hook file carry a long member list that CI cannot reuse.
|
||||
- **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check.
|
||||
- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about.
|
||||
- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added.
|
||||
- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports one `full check` job, and the runner reports per-gate timing inside that job, so a slow local checkpoint still points at the gate that dominates the run.
|
||||
|
||||
The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path.
|
||||
|
||||
`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
@@ -51,7 +51,7 @@ The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws w
|
||||
A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct:
|
||||
|
||||
1. The **stdout transcript** — the framed `session/update` JSON-RPC the editor sees. Catches regressions in the ACP bridge's event→update translation (`streamSessionEventUpdate`). Compared against a committed `stdout.golden.jsonl`.
|
||||
2. The **re-persisted session JSONL** — the log the replay run itself persists, compared against the scenario's `session.jsonl`. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. There is no separate session golden: `session.jsonl` is BOTH the replay source (recorded scenarios) and the expected produced log. Both sides pass through `normalizeSessionLog` before comparing — the fixture is raw-harvested (its own real session id / cwd / timestamps) and the replay output has fresh ones, so each is scrubbed against ITS OWN volatile values (the fixture's read from its header line) and the comparison is on normalized form. For an authored override scenario the same `session.jsonl` holds the expected produced log; `replay.override.json` drives the model, and `llm-replay` ignores the fixture for model chunks when an override exists, so committing the expected log there does not affect replay.
|
||||
2. The **re-persisted session JSONL** — the log the replay run itself persists, compared against the scenario's `session.jsonl`. Catches regressions in the loop, tool dispatch, and turn/step structure that never surface on stdout. There is no separate session golden: `session.jsonl` is BOTH the replay source (recorded scenarios) and the expected produced log. Both sides pass through `normalizeSessionLog` before comparing — the fixture is raw-harvested (its own real session id / cwd / timestamps) and the replay output has fresh ones, so each is scrubbed against ITS OWN volatile values (the fixture's read from its header line) and the comparison is on normalized form. Request-header CONTENT (the composed system prompt + tool schemas) is additionally scrubbed to `{{system}}`/`{{tools}}` tokens on both sides — in the stored fixtures too — for every scenario except the one that pins it ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)). For an authored override scenario the same `session.jsonl` holds the expected produced log; `replay.override.json` drives the model, and `llm-replay` ignores the fixture for model chunks when an override exists, so committing the expected log there does not affect replay.
|
||||
|
||||
The two are genuinely additive: stdout is the bridge's *lossy projection* of the log (it drops `assistant/message.usage`, `step/*`, exact `seq`/`time`, and renders tool I/O differently), so a loop/tool/turn-structure regression can change the JSONL while leaving the stdout projection identical, and a bridge-translation regression can change stdout while the JSONL is untouched. Asserting the JSONL equality also echoes the proposed [universal replay fixture](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md) idea.
|
||||
|
||||
@@ -68,7 +68,7 @@ The replay plugin lives in its own package, `@deepseek-ai/dsh-llm-replay` (`pack
|
||||
|
||||
### Two subcommands, replay in the default gate
|
||||
|
||||
`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` + `session.jsonl` for ALL scenarios — the harness passes `<dir>/session.jsonl` to `llm-replay` unconditionally, so even a no-model scenario needs its header-only fixture or `loadReplayScript()` fails; `replay.override.json` additionally for authored model scenarios).
|
||||
`pnpm run test:snapshot` runs replay (keyless) and is composed into the default `pnpm run test` gate so every PR gets the regression check (the main `vitest.config.ts` include stays narrow; the gate is `test && test:snapshot`). `pnpm run test:snapshot:record` requires `DEEPSEEK_API_KEY` (loaded from repo `.env` first), hits the real API, harvests the produced `session.jsonl` (the replay source AND the expected-log artifact), and `--update`s the stdout golden in one pass. Both forward a scenario filter. A missing fixture in replay **fails loud** with a "record first" message rather than self-skipping (the e2e self-skip rule is a CI-secret accommodation, not appropriate here — a committed-fixture test that silently vanishes is a coverage hole). A no-model scenario's `session.jsonl` simply has no `assistant/chunk` events (empty derived script); fail-loud still applies if a model call happens with no entry. An orphan-fixture guard test fails on a golden/fixture not referenced by any scenario (Vitest does not prune orphaned raw goldens), and a per-kind required-fixture guard asserts each scenario ships exactly the files its kind needs (`input.json` + `stdout.golden.jsonl` + `session.jsonl` for ALL scenarios — the harness passes `<dir>/session.jsonl` to `llm-replay` unconditionally, so even a no-model scenario needs its header-only fixture or `loadReplayScript()` fails; `replay.override.json` exactly for the scenarios whose table entry sets `overridden` — required with the flag, forbidden without it, because the harness forwards the sidecar purely on file existence and an unregistered stray would silently replace the derived script).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ This RFC records the decision to add a **second, secret-consuming workflow** tha
|
||||
|
||||
## Decision
|
||||
|
||||
Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. ci.yml is left untouched.
|
||||
Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. The keyless workflow remains separate so forkable quality gates and secret-consuming real-API gates keep different trigger and credential policies.
|
||||
|
||||
### A separate workflow, not a job in ci.yml
|
||||
|
||||
@@ -20,7 +20,7 @@ ci.yml's value is that it is keyless, forkable, and always-green: any contributo
|
||||
|
||||
### Cost is not the constraint; reliability is
|
||||
|
||||
The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all six `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy.
|
||||
The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all matching `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy.
|
||||
|
||||
### Triggers: trusted events only
|
||||
|
||||
@@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
|
||||
|
||||
### Scope, runtime shape
|
||||
|
||||
Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's `[24, 26]` matrix already owns; a second Node version would double real-API calls for no added signal. `timeout-minutes: 45` bounds a wedged run given serial files (`fileParallelism: false`), 120s/test, and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled.
|
||||
Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the primary line): these tests exercise API integration, not node-version compatibility, which ci.yml's Node 22.19/24/26 matrix owns. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `14`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -32,4 +32,4 @@ Reviewers lose one artifact name that made the expected persisted log visually s
|
||||
|
||||
## Implementation note
|
||||
|
||||
The comparison normalizes BOTH sides, but each against its OWN volatile values, not a shared context. A raw harvested `session.jsonl` bakes in the recording run's session id, cwd, and timestamps; the replay run produces fresh ones. `normalizeSessionLog` scrubs cwd by exact string match, so normalizing the fixture against the *replay* run's cwd would leave the recorded cwd in the header unscrubbed and the compare would fail. The harness therefore derives the fixture's normalize context from its OWN header line (`{ type:'session', id, cwd }`) — `fixtureContext()` in `acp.snapshot.ts` — so both sides scrub to the same `{{sessionId}}`/`{{cwd}}` tokens. An authored fixture copied from the old golden already carries the normalized header (`id:'{{sessionId}}'`, `cwd:'{{cwd}}'`), which yields those tokens as the volatile values and scrubs idempotently. The session-log side uses a plain normalized-string `toEqual`, NOT `toMatchFileSnapshot`, so a run never overwrites the fixture.
|
||||
The comparison normalizes BOTH sides, but each against its OWN volatile values, not a shared context. A raw harvested `session.jsonl` bakes in the recording run's session id, cwd, and timestamps; the replay run produces fresh ones. `normalizeSessionLog` scrubs cwd by exact string match, so normalizing the fixture against the *replay* run's cwd would leave the recorded cwd in the header unscrubbed and the compare would fail. The harness therefore derives the fixture's normalize context from its OWN header line (`{ type:'session', id, cwd }`) — `fixtureContext()` in `dsh-acp-snapshot`'s suite module — so both sides scrub to the same `{{sessionId}}`/`{{cwd}}` tokens. An authored fixture copied from the old golden already carries the normalized header (`id:'{{sessionId}}'`, `cwd:'{{cwd}}'`), which yields those tokens as the volatile values and scrubs idempotently. The session-log side uses a plain normalized-string `toEqual`, NOT `toMatchFileSnapshot`, so a run never overwrites the fixture.
|
||||
@@ -0,0 +1,32 @@
|
||||
# RFC: Pin request-header content in one snapshot scenario
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Every model-driving ACP snapshot fixture (`session.jsonl`) embedded the full composed system prompt and the complete tool-schema list in its `request/header` event — roughly 8 KB on one line, per fixture. That content is identical across the suite (byte-identical tool list everywhere, including subagent children; identical prompt modulo each recording's temp cwd), so any change touching a tool description or a system-prompt line had to update every fixture: re-record everything against the live API (churning model responses and stdout goldens along the way) or hand-edit ~35 giant header lines. Introducing the dynamic-workflows feature — one new tool plus one prompt paragraph — rewrote every snapshot fixture in the repo, burying the behavioral diff a reviewer should be reading.
|
||||
|
||||
## Decision
|
||||
|
||||
Exactly one scenario — `text-turn`, flagged `pinsHeader` in the `acp.snapshot.ts` scenario table — commits and compares the full request-header content; the pin mechanics live in [`dsh-acp-snapshot`](../../../../packages/support/acp-snapshot/README.md), whose suite factory enforces one pin per consuming suite. Every other fixture stores and compares that content as stable tokens via the pure normalizer `scrubRequestHeaders` in that package's `normalize.ts`: a `request/header` event's `header.system` becomes `"{{system}}"` and `header.tools` becomes `"{{tools}}"`; a `request/header-delta` keeps its structural facts — the system delta's `keepStart`/`keepEnd` line positions with one `{{system}}` token per inserted line, the tools delta's added/removed/changed tool names — and tokenizes only the bulk (prompt text, schema bodies), so two different deltas still compare different. The scrub is composed in front of `normalizeSessionLog` on BOTH sides of a non-pinning scenario's log compare and applied to the harvested logs record mode writes, so a re-record cannot smuggle the content back. Absent fields stay absent — WHETHER a header carried a prompt or tools is behavior and stays visible — and `config`/`reason` stay verbatim: a model swap churns every fixture by design (it invalidates the recorded responses), while a prompt or schema edit churns none of them (replay derives model behavior exclusively from `assistant/chunk` events and never reads header content — see `dsh-llm-replay`).
|
||||
|
||||
A system-prompt or tool-schema change therefore lands as exactly one committed-fixture diff — the pinned `text-turn` header line — updated by hand or by re-recording that one scenario (`pnpm run test:snapshot:record` with `-t text-turn`).
|
||||
|
||||
Guards make the split self-enforcing. On disk (fixture meta-tests): every non-pinning `session*.jsonl` must be a fixed point of `scrubRequestHeaders` (unscrubbed content crept in — apply the scrub), the pinning scenario's fixture must NOT be one (the pin lost its content), and exactly one scenario must pin. Live (every non-pinning scenario run): each `request/header` the run produces — parent, spawn child, fork child, initial or resume — must equal the pinned fixture's header after both sides normalize their own volatile values, and no `request/header-delta` may appear at all (a mid-run header change diverges from the pin by construction, and its content would be invisible under the scrub), so the single-pin premise is asserted rather than assumed.
|
||||
|
||||
One pin covers the whole suite because every session — parent, spawn child, fork child — composes the identical tool list and the identical prompt modulo cwd, and the uniformity guard fails the suite the moment that stops holding. If header composition ever becomes session-dependent by design (a restricted subagent toolset, say), the divergent shape gets its own pinning scenario.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Re-record or hand-edit every fixture per change** — the status quo; the churn this RFC removes.
|
||||
- **Scrub at compare time only, keeping fixtures raw** — the compares go green without fixture edits, but every committed fixture then carries a permanently stale copy of the prompt and schemas: dead weight that misleads readers and still rewrites wholesale on the next re-record. Storing the tokens keeps the fixture honest about what it does and does not pin.
|
||||
- **Scrub everywhere, pin nowhere** — loses the only end-to-end record of the composed header as actually sent (prompt assembly, registered-tool order, full schemas). The generated tool catalog documents each tool in isolation; only a real fixture pins the composed set.
|
||||
- **Slim the session log itself (log a content digest, store the header elsewhere)** — violates the reconstructability contract: the product log must reproduce each request bit-for-bit ([reconstructable-requests RFC](../architecture/2026-07-05-reconstructable-requests.md)). Header bulk is a test-artifact concern, solved in test normalization; the live log is untouched.
|
||||
|
||||
## Verification
|
||||
|
||||
All 37 snapshot scenarios replay green with the scrubbed fixtures (the committed fixtures were rewritten once through `scrubRequestHeaders` itself; `text-turn` untouched). The fixed-point, pin-retains-content, exactly-one-pin, live header-uniformity, and no-unpinned-delta guards run inside the suite, and `scrubRequestHeaders` has unit coverage for both header event types, delta structure preservation (line positions, insert arity, tool names), absent-field preservation, config/reason retention, byte-for-byte pass-through of other lines, and idempotence.
|
||||
|
||||
## Consequences
|
||||
|
||||
A tool-description or system-prompt change churns one committed fixture line instead of every fixture in the suite, so snapshot diffs read as behavior again, and ~270 KB of duplicated header bytes leave the repo. The cost: non-pinning fixtures no longer display header content, so reading one shows tokens where the prompt and schemas were — the pinned `text-turn` fixture is the place to look, and the live uniformity guard guarantees it speaks for every session in the suite. A header change surfaces as a suite-wide test failure whose fix is the one pinned line, rather than as ~35 fixture rewrites.
|
||||
@@ -0,0 +1,36 @@
|
||||
# RFC: Extract the ACP snapshot suite into a support package
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests).
|
||||
|
||||
A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was already triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness (`TODO(acp-test-harness)`). Location also decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all.
|
||||
|
||||
## Decision
|
||||
|
||||
The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) (`@deepseek-ai/dsh-acp-snapshot`); an example's `*.snapshot.ts` is its scenario table, its agent paths, and one factory call, over its own `snapshots/` fixtures and `cordis.snapshot.yml` overlay ([single-source replay config](2026-07-04-single-source-acp-replay-config.md)). Reading `DSH_SNAPSHOT` stays at that edge — the library takes a resolved `mode`.
|
||||
|
||||
**`src/harness.ts`** — `runScenario` and the input-script/result types, parameterized by an `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`; absolute paths the consuming suite resolves from its own `import.meta.url`). The client's `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`.
|
||||
|
||||
**`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions.
|
||||
|
||||
**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record-mode fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin, non-pinning fixtures are `scrubRequestHeaders` fixed points). The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each suite flags exactly one `pinsHeader` scenario (the factory throws on zero, a meta-test rejects more than one; WHICH scenario pins is the table's reviewable choice), and the uniformity guard compares only that suite's sessions. The pure helpers (`childFixturePaths`, `fixtureContext`, `normalizedHeaders`, `headerDeltaCount`) are exported for direct unit coverage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Copy the modules into each example** — the fork this RFC exists to prevent: the record/guard logic is exactly the code that must stay byte-identical across suites, and examples are outside the coverage gate, so each copy is also unmeasured.
|
||||
- **A shared module directory under `examples/`** — keeps the code outside the coverage gate and forces relative imports across example boundaries, against the package-name import convention; `examples/` leaves stay thin by design.
|
||||
- **A `/testing` subpath export of `dsh-acp-agent`** — couples test infrastructure into a product package's surface and dependency set; `packages/support/` exists precisely for real-but-lower-compatibility dev/test packages, with `dsh-llm-replay` as the precedent this package completes.
|
||||
- **Export raw test-body functions instead of a suite factory** — each example would re-own the `describe`/`it` skeleton (~80 lines of registration boilerplate per suite) for no flexibility gain; the factory keeps consumers to a scenario table plus one call, and the exported pure helpers preserve unit-testability inside the factory design.
|
||||
- **An injectable ACP `Client` factory instead of declarative `permissionAnswers`** — maximally flexible, but it leaks SDK client construction to every consumer and reopens per-example drift in exactly the layer being unified; a declarative queue keeps `input.json` the single scripting surface and stays golden-normalizable.
|
||||
- **Generalize beyond ACP (a transport-agnostic snapshot harness)** — no second transport exists; the harness is ACP-shaped end to end (SDK client, JSON-RPC frames, `session/update` waiters), and a speculative abstraction would be a seam split ahead of any consumer.
|
||||
|
||||
## Testing
|
||||
|
||||
Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the REAL spawn path by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` covers every step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), env forwarding, workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. Two structurally unreachable guards carry reasoned `v8 ignore` comments. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`).
|
||||
|
||||
## Consequences
|
||||
|
||||
A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands.
|
||||
@@ -0,0 +1,139 @@
|
||||
# RFC: Code Mode — the model writes TypeScript against the tool registry
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request.
|
||||
|
||||
For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not.
|
||||
|
||||
Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result.
|
||||
|
||||
An earlier draft of this RFC designed Code Mode as an add-on consumer plugin with zero core changes, deferring the execution substrate to a follow-up. Both constraints are dropped here, deliberately. First, the harness is pre-release and optimizes for the correct foundation over blast radius: tool presentation is the registry's own concern, and bolting a second presentation onto it from outside means transforming the registry's contribution after the fact — a waterfall listener whose correctness depends on listener ordering, which fights the [reconstructable-requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) design instead of riding it (that refactor removed request mutation from `agent/request`, the seam the old draft relied on). Second, the substrate question is answerable now: a Node `worker_threads` runtime gives real containment — separate isolate, empty environment, heap caps, and a `terminate()` that reliably stops a hot synchronous loop — where the old draft's `node:vm` stub had none of those, and it fits the harness's existing trust model (§Trust posture) without a hardening follow-up.
|
||||
|
||||
## Proposal
|
||||
|
||||
Three decisions, each elaborated in its own section below:
|
||||
|
||||
1. **Code Mode is a first-class presentation mode of `ToolRegistry`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (today's behavior, the default), `'code'` (the wire carries exactly one tool, `run_code`, plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas *and* `run_code` + SDK). The registry's existing tool-schema provider contributes whatever the mode dictates, so the wire tool list is shaped at its source — no interception, no listener-ordering caveats — and the logged request header records it for free.
|
||||
2. **Code execution is a capability seam** — a new group `packages/code-runtime/` with the interface package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime` ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md); consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is a new implementation package, not a redesign.
|
||||
3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority.
|
||||
|
||||
### The registry owns the mode
|
||||
|
||||
`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.** The registry already feeds the assembly through `ctx.systemPrompt.tools(() => this.schemas())`; the provider becomes mode-aware: `'native'` contributes all schemas (unchanged), `'code'` contributes only `run_code`'s schema, `'both'` contributes all schemas plus `run_code`. Because [`PromptAssembly.tools` is the single source the loop's request header snapshots](../../../../packages/core/system-prompt/src/index.ts), the collapse is automatically logged and reconstructable — model-visible ⟺ logged holds with zero new mechanism. Scope of the guarantee, stated honestly: the mode governs the **registry's** contribution, and the registry is the only shipped schema source — but `systemPrompt.tools()` is a public multi-provider API and the `system-prompt/assemble` waterfall may transform the assembly, so a deployment that wires a second direct provider (or a mutating listener) owns what it adds, exactly as in native mode. Those are deliberate acts; what the design eliminates is the *accidental* leak the old draft worried about — a listener-ordering race around an after-the-fact collapse — and the shipped-configuration invariant (`'code'` ⇒ assembled tools exactly `[run_code]`) is pinned by tests and, like every request, by the logged header.
|
||||
|
||||
**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native tools rejects every assembly under `mode: 'code'` (those names are no longer contributed), by the existing fail-loud rule for unlisted names. This is correct behavior, not a bug: a deployment switching modes 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, at each assembly, a TypeScript declaration of every registered tool except `run_code` itself, plus fixed usage instructions. The thunk reads the live store and emits tools in lexicographic name order, so its output is deterministic and stable across steps — an unchanged tool set produces byte-identical text (prefix-cache-friendly; a mid-session registration surfaces as one logged header delta, exactly like a native-mode tool change).
|
||||
|
||||
**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.
|
||||
|
||||
### The run_code tool and the dispatch bridge
|
||||
|
||||
Under `'code'` and `'both'` the registry registers `run_code` in itself as an ordinary tool — one required parameter `{ code: string }` — so the unchanged loop dispatches it through the normal pipeline and `tools/pre-execute` / `tools/post-execute` gate it like any other call (a permission plugin can inspect the program text before it runs). 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 registered tool except `run_code`, the binding is an async function that (a) checks the run signal before and after (throwing stops the program — necessary because `ctx.tools.execute()` converts errors to `isError` data), (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: normalizing BEFORE dispatch makes the dispatched form and the logged form the same JSON value by construction, so an executed sub-call can never fail at logging time, (c) awaits its turn on the **per-run serialization queue** (below), (d) calls `this.execute({ callId, name, arguments, agent: exec.agent, signal: runSignal })` with a deterministic sub-id `` CallId(`${exec.callId}:code:${n}`) `` — the run signal, not the bare outer one, so a budget expiry aborts an in-flight sub-tool (`bash-local` kills on its spec signal) instead of orphaning it, (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, an MVP limitation), `isError` → **the binding rejects** with an `Error` carrying the result text. Rejection is the deliberate model-facing contract — real code signals failure by throwing, `try/catch` and `Promise.all` short-circuiting behave as every model has seen them behave — where the old draft's `{ output, isError }` envelope made error handling a bespoke convention.
|
||||
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: exec.signal })`.
|
||||
3. **Surfaces the outcome — after reaching quiescence.** When `ctx.codeRuntime.run()` resolves, 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**, 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` returns. A successful run 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 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); the registry's existing catch turns it into a structured `isError` result.
|
||||
|
||||
**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.
|
||||
|
||||
**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 still 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 once tools can declare themselves concurrency-safe is deferred work, same as before.
|
||||
|
||||
**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 `Run code`, `rawInput` = the program text; `presentResult` → a `generic` card whose content is the captured output (from `meta`). 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.
|
||||
|
||||
### The code-runtime seam
|
||||
|
||||
`packages/code-runtime/code-runtime/` — `@deepseek-ai/dsh-code-runtime`, depending only on `cordis`. An abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) plus the vocabulary:
|
||||
|
||||
- `CodeRunRequest = { program: string; bindings: CodeBindingNamespace[]; signal?: AbortSignal }`
|
||||
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<unknown>> }` — the runtime exposes each namespace as a global object of async functions inside the program; binding arguments and resolutions must be structured-cloneable (a runtime may cross a serialization boundary; ours does).
|
||||
- `CodeRunResult = { value?: unknown; logs: CodeLogEntry[]; error?: CodeRunFailure }` — an error is a field on a resolved result, never a rejection of `run()`.
|
||||
- `CodeLogEntry = { source: 'console' | 'stdout' | 'stderr'; level?: 'log' | 'info' | 'warn' | 'error' | 'debug'; text: string }`
|
||||
- `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 established 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 as above. The seam split is justified by real planned divergence on both axes — substrate (worker now; container/microVM later) and language (the Python/AssemblyScript direction sketched in the earlier draft survives as future work) — not by speculation: `dsh-tools` consumes the interface today and tests against a trivial in-repo fake, exactly the interface/implementation/consumer shape of the bash template.
|
||||
|
||||
### The worker-thread runtime
|
||||
|
||||
`@deepseek-ai/dsh-code-runtime-worker`, the second package of the `packages/code-runtime/` group. Per `run()`:
|
||||
|
||||
1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker.
|
||||
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`.
|
||||
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**, and the RFC says so without ceremony. 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 already 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 and is stated plainly: `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 already is for bash. Code Mode is gated where bash is gated — `tools/pre-execute`, where permission/sandbox plugins veto or approve the program before it runs — and adds containment bash does not have: empty env, heap caps, hard termination of the program itself, a separate isolate. The earlier draft's two-flag unsafe ceremony (`{ unsafe: true }` constructor + `allowUnsafeRuntime`) existed for a `node:vm` stub with *no* containment and is dropped with it; demanding scarier flags for the better-contained executor than for bash 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 exists so such a deployment can tell backends apart.
|
||||
|
||||
### 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.
|
||||
|
||||
## Plan
|
||||
|
||||
Four stacked PRs, each gates-green (`typecheck`, `lint`, `test:coverage`, `test:snapshot`, `doc-sync`, `verify-module-graph`, `build`, `hygiene`) with docs updated in the same change:
|
||||
|
||||
1. **This RFC revision** (docs-only): the file rewritten as above (renamed `2026-06-15-code-mode.md`, same first-proposed date), regenerated RFC index.
|
||||
2. **`dsh-code-runtime`** (interface package): the group `packages/code-runtime/`, abstract `CodeRuntime`, vocabulary types, ctx-key declaration; docs in the same change — group README + package README, the `packages/README.md` group table row, the `ctx.codeRuntime` row in [docs/architecture.md](../../../architecture.md)'s service map, and the regenerated cordis catalog (the new service class). Unit tier: HMR safety (dispose removes `ctx.codeRuntime`), contract docs. This package has no behavior to snapshot or e2e; its coverage story is unit-only by design.
|
||||
3. **`dsh-code-runtime-worker`**: the implementation above, plus the regenerated config catalog (its `Config`). Unit tier (real workers, no mocks — they are cheap and local): output/value capture, log source attribution, error kinds (exception incl. non-erasable syntax, abort, worker-exit under OOM), the two budgets from both sides (a hot loop with an un-awaited pending dispatch still dies at `computeMs` busy time; a program idling on a slow binding outlives `computeMs` and dies only at `maxWallMs`), binding bridge hostility cases (unknown name, duplicate id, post-settlement message, `__proto__`/`constructor`/`toString` binding names), structured-clone fallback, cap truncation, `env` emptiness verified from inside the program, dispose-awaits-exit. A real-load-path test runs the built package (`lib/`) so the worker entry resolves both unbuilt (tsx) and built — the published-bin guard from [docs/testing.md](../../../testing.md).
|
||||
4. **Native code mode in `dsh-tools`** + the end-to-end surface: mode config, provider switch, `tools:sdk` section, `jsonSchemaToTs`, `run_code` + dispatch bridge + serialization queue, `tool/code-dispatch` event; regenerated tool, config, and persistence catalogs; [docs/architecture.md](../../../architecture.md) (tool-pipeline prose) and the [adding-a-tool cookbook](../../../cookbook/adding-a-tool.md) cross-reference updated in the same change; an `examples/` leaf + `demo:code` script wiring the worker runtime with `mode: 'code'`; move this RFC to `implemented/`. Coverage named per tier now, per the plan-time rule: **unit** — codegen table (DSL subset, quoted names, `unknown` degradation, determinism), provider contribution per mode, `toolOrder × mode` rejection, missing-runtime/wrong-language loud failures, serialization non-overlap (a probe tool records enter/exit under `Promise.all`), abort stops the queue, binding rejection on `isError`, `CodeRunFailedError` → structured `isError`, event payloads, `deriveMessages()` ignores the event, HMR safety (mode flip via config reload removes tool + section); **e2e (with-key, self-skips)** — a real model, `mode: 'code'`, a task requiring two tool calls and curation, asserting the wire tool list was exactly `run_code` and the transcript's dispatch events; **snapshot (keyless replay)** — goldens for a `run_code` turn in `'code'` and `'both'`, pinning the SDK section text, the collapsed header tools, dispatch events, and the result card.
|
||||
|
||||
The four PRs land in order (each on the previous); per stacked-review practice, review fixes land on the PR that introduced them and merge down.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An add-on consumer plugin, zero core changes (the previous draft of this RFC).** Rejected on both halves. The wire-collapse half aged out from under it: it targeted the `agent/request` waterfall, which [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) has since re-typed to call-config-only, and the surviving alternative — transforming the assembly a waterfall listener receives — is strictly worse than contributing the right list in the first place (transformation must undo `toolOrder` canonicalization it cannot see the config for, and its correctness depends on where it sits in a listener chain). The deeper reason is ownership: which tools the model is offered, in which representation, is the registry's single concern — `schemas()` for function calling and the SDK for Code Mode are two projections of one store, and splitting the second projection into a satellite package would preserve a boundary the domain does not have.
|
||||
|
||||
**`node:vm` as the reference runtime, hardening deferred (also the previous draft).** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm), cannot interrupt a hot loop, and forced the draft into a two-flag unsafe ceremony plus a mandatory follow-up RFC. The worker thread delivers the missing properties now — separate isolate, empty env, `resourceLimits`, reliable `terminate()` (all verified by probe before this revision) — at bash-equivalent trust, so the reference implementation and the production one are the same package and the ceremony dissolves.
|
||||
|
||||
**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s (now cheap to add as a logged surface replace, per the reconstructable-requests consequences) still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls.
|
||||
|
||||
**Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together.
|
||||
|
||||
**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it.
|
||||
|
||||
**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred again, knowingly: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and every learning it depends on (how models actually split usage under `'both'`) arrives only after this ships.
|
||||
|
||||
**Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine.
|
||||
|
||||
**A REPL-style persistent kernel** (state survives across `run_code` calls). Rejected for the MVP: cross-call state would be invisible to the session log, breaking the reconstructability guarantee that every request is a pure function of the log; fresh-per-run keeps it. A kernel-style backend remains expressible behind the seam later, with its own logging story.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `mode: 'native'` (and unset) is byte-for-byte today's behavior: same assemblies, same headers, same snapshots.
|
||||
- Under `mode: 'code'`, the assembled tool list (and thus the logged `request/header`) is exactly `[run_code]`; under `'both'`, every native schema plus `run_code`; the `tools:sdk` section is present in both, absent under `'native'`, and its text is deterministic for a fixed tool set (byte-identical across consecutive assemblies).
|
||||
- The generated `.d.ts` covers every registered tool except `run_code`, non-identifier names via quoted keys, unsupported schema constructs as `unknown`, without codegen ever throwing.
|
||||
- A program calling two tools returns only its curated output; each sub-call appears as a `tool/code-dispatch` event ordered by log `seq`, flows through `tools/pre-execute`/`post-execute` (a deny reaches the program as a binding rejection), and never enters derived messages; a binding argument that does not survive JSON normalization (`BigInt`, a circular structure) rejects before dispatch — nothing executes unlogged.
|
||||
- `Promise.all` over three bindings produces non-overlapping `ctx.tools.execute()` intervals (probe-tool assertion); aborting mid-program stops the worker and dispatches nothing further; a budget expiry during a slow sub-dispatch aborts that dispatch (the probe tool observes its signal fire), `run_code` returns only after the queue drains, and no `tool/code-dispatch` event lands after `run_code`'s own `tool/result` in the log.
|
||||
- Worker runtime: a hot `for(;;){}` run ends at the `computeMs` busy-time budget with `error.kind: 'timeout'` — including when the program fired an un-awaited binding call first (the pending-RPC decoy); a program idling on a slow binding does not consume `computeMs` and is bounded only by `maxWallMs`; OOM under `resourceLimits` yields `kind: 'worker-exit'` with the host process healthy; `process.env` inside a program is empty; non-erasable syntax yields `kind: 'exception'` without a worker spawn; disposal awaits worker exit.
|
||||
- Misconfiguration is loud before any model request: non-native mode with no `ctx.codeRuntime`, a runtime whose `language ≠ 'typescript'`, and `toolOrder` naming a non-contributed tool all reject the assembly with actionable messages.
|
||||
- The demo runs against the real API via `demo:code`; the snapshot goldens replay keylessly; all repo gates pass on every PR of the stack.
|
||||
|
||||
## Risks
|
||||
|
||||
**The worker is not a hard security boundary.** Deliberate and documented (§Trust posture): posture equals the existing bash tool, containment exceeds it, gating uses the same seams. Deployments needing more need a future `isolation: 'container'` backend — tracked as the seam's designed extension, not a TODO on this design.
|
||||
|
||||
**`stripTypeScriptTypes` is marked experimental.** It is the same engine (amaro/swc) behind Node's own native `.ts` execution, exposed as an API across this repo's whole engines range. Mitigations: the runtime's unit suite pins the behaviors relied on (position preservation, erasable-only rejection message shape loosely), the call sits behind one private function, and `amaro`/`sucrase` are drop-in replacements if the API shifts. The erasable-only subset is a model-facing contract line, and the error path is a working feedback loop, not a dead end.
|
||||
|
||||
**Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the RFC makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass.
|
||||
@@ -1,119 +0,0 @@
|
||||
# RFC: Optional Code Mode — model writes TypeScript against an SDK of all tools
|
||||
|
||||
Status: proposed
|
||||
|
||||
> Premise partially stale: this proposal predates [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) — `agent/request` now shapes call config only (no request/content mutation), so the interception points named below need re-mapping onto the log channels and `system-prompt/assemble` before implementation.
|
||||
|
||||
## Problem
|
||||
|
||||
Today the agent loop advertises every registered tool to the model as a native JSON-schema function definition. `ToolRegistry` feeds its schemas into `ctx.systemPrompt`, the loop puts them on `GenerateOptions.tools`, and the adapter serializes them to the provider's function-calling wire format. The model then invokes one `tool-call` block per step, the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../architecture.md)), and **every** intermediate `tool-result` re-enters the model's context on the next request.
|
||||
|
||||
For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each of those round-trips drags the entire intermediate result back into context whether the model needs it or not.
|
||||
|
||||
Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) (shipped as the `@cloudflare/codemode` npm package) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated SDK that wraps all the tools, and that program is executed. The model curates what comes back — only what it `console.log`s and/or returns — instead of every intermediate result. The SDK functions are async, so the model can *express* fan-out (`Promise.all`) naturally in code; this RFC initially **serializes** those dispatches (§ Concurrency) until the tool contract grows concurrency-safety metadata, so the early win is composition and fewer round-trips, not parallelism.
|
||||
|
||||
This RFC proposes an **optional** Code Mode for the DeepSeek Harness, covering **all** tools uniformly — built-in and future MCP — with no per-tool work, implemented Cordis-style with **zero core-package changes**. It fully specifies the code-execution seam and the SDK-generation pipeline, but ships only a minimal `node:vm` reference stub for execution; the hardened, sandboxed execution substrate is **deferred to a follow-up RFC** (see Risks). This RFC does not change the agent loop, and it leaves native tool-calling exactly as it is — Code Mode is a plugin you load, not a replacement.
|
||||
|
||||
## Proposal
|
||||
|
||||
The design follows the codebase's capability-seam pattern ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md), the `bash` template) as a three-package split, plus one consumer plugin. Nothing in `dsh-session`, `dsh-agent`, `dsh-agent-loop`, `dsh-llm`, `dsh-tools`, or `dsh-system-prompt` changes.
|
||||
|
||||
**Prior art.** `@cloudflare/codemode` validates this shape directly and several of its decisions are adopted below. Its `Executor` interface is deliberately tiny — `execute(code, fns) → { result, error?, logs? }` — with a production `DynamicWorkerExecutor` (isolated Workers) and a six-line `NodeVMExecutor` example as two implementations behind it: exactly the interface/implementation split [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) prescribes. It generates TypeScript type definitions from tools for the model's context and runs the generated JavaScript in a sandbox, capturing console output alongside the return value. It normalizes model output into an async arrow function via AST parsing (acorn) and sanitizes tool names into valid JS identifiers (`my-tool` → `my_tool`, `delete` → `delete_`). It blocks outbound network by default. The transferable lessons — minimal executor contract, host-side type derivation, capture-output-and-return-value, name sanitization, AST-normalize the code, isolate by default — are folded into the design below. What does **not** transfer is the substrate: Cloudflare's isolation is Workers-specific; our equivalent hardened substrate is the deferred follow-up.
|
||||
|
||||
**Prompt-budget tradeoff (Code Mode is not unconditionally cheaper).** Deriving the SDK types host-side costs no extra *discovery* round-trip, but the generated `.d.ts` is injected into the system prompt (§3a), so the type definitions themselves **do** consume context — and for an all-tools SDK that cost scales with every registered tool and can be comparable to, or larger than, the native JSON schemas it replaces. Code Mode's saving is on the **output/result** side (the model curates what comes back; intermediate results never re-enter context) and on **round-trips** (compose many calls in one program), not on the input-side tool description. The net win is workload-dependent: it pays off for multi-call, large-intermediate-result workflows and can cost *more* for a single call against a large tool surface. The `.d.ts` section is a prefix-stable prompt prefix, so prompt caching amortizes its per-turn cost across a session; the RFC notes that caching is what keeps the injected SDK affordable, and that a deployment with a very large tool surface should weigh the SDK size against native schemas rather than assume Code Mode is strictly cheaper.
|
||||
|
||||
**1. Interface package `packages/code-runtime/`** — a new package `@deepseek-ai/dsh-code-runtime` owning `ctx.codeRuntime`, depending only on `cordis`. It defines an abstract `CodeRuntime extends Service` plus the execution vocabulary. The runtime knows **nothing** about `ctx.tools`: it is handed a set of named async functions (the resolved SDK bindings), runs the program, and captures output. The result shape mirrors Cloudflare's proven-minimal contract so an error is a *field on a resolved result*, not a throw the runtime is expected to make:
|
||||
|
||||
- `CodeRunRequest = { code: string; sdk: SdkBinding[]; signal?: AbortSignal }`
|
||||
- `CodeRunResult = { result: unknown; logs: string[]; error?: string }`
|
||||
- a readonly `safe: boolean` on the `CodeRuntime` service — `false` for an unsandboxed stub, `true` only for a real isolating substrate; consumers gate on it (§2).
|
||||
- `SdkBinding = { namespace: string; fns: Record<string, (args: unknown) => Promise<unknown>> }`
|
||||
|
||||
Per the "explicit > implicit at seams" convention, the request spells out every field the runtime acts on; defaulting (e.g. an output cap, a timeout derived from `signal`) is the implementation's explicit job, not a hidden `?? default` inside `run()`. The split into interface + implementation is justified under [the capability-seam pattern](../../implemented/architecture/2026-06-13-capability-seams.md) because there is **genuinely more than one planned implementation** — the node:vm stub *and* the hardened substrate (a real isolate, or the generated program run as a sandboxed process through the existing `ctx.bash` seam) that is scheduled follow-up work, not speculative optionality. The capability-seam pattern warns against splitting preemptively when only one implementation is conceivable; here a second is not just conceivable but required before any untrusted use, so the seam earns its keep.
|
||||
|
||||
**Backends can differ by language/runtime, not only by trust level.** The two implementations above (unsafe stub vs. hardened substrate) differ along the *trust* axis while staying TypeScript/JS, but nothing in the `CodeRuntime` contract — a program string plus a set of named async SDK bindings in, and a `{ result, logs, error? }` out — is bound to one source language. The same seam can host backends that differ along the *language* axis, executing a program written in something other than TypeScript. Two illustrative directions:
|
||||
|
||||
- **An AssemblyScript backend.** AssemblyScript is a strict TypeScript subset that compiles to WebAssembly, so a program stays familiar to a TS-fluent model while the WASM boundary supplies exactly the sandboxing the hardened substrate is meant to provide — memory isolation and no ambient host authority come from the runtime rather than from after-the-fact hardening of `node:vm`. This is an appealing route to a `safe = true` backend.
|
||||
- **A Python backend.** Python is arguably the model's most native language — it has seen far more real Python than any tool-calling trace — which is the same "LLMs write better code than tool calls" argument that motivates Code Mode, taken one step further. A Python backend is itself a sub-seam over different Python *runtimes*: **CPython** (in-process or a sandboxed subprocess via `ctx.bash`) for maximum fidelity and ecosystem access, or a more controllable / embeddable interpreter — Pyodide (CPython on WASM), RustPython, or a restricted embedded interpreter — when isolation, deterministic resource limits, or a clean capability boundary matter more than running arbitrary native extensions.
|
||||
|
||||
These are illustrations of the seam's reach, **not commitments** — the MVP ships only the TypeScript path. The honest caveat is that the *execution* contract is language-agnostic but the *presentation* is not: the SDK-generation pipeline below (§3a and the `jsonSchemaToTs` codegen, which emits a TypeScript `.d.ts`) is TypeScript-specific, so a non-TS backend pairs the shared `CodeRuntime` contract with its own language-appropriate SDK generator and system-prompt section (a `.pyi` stub and Python usage instructions for the Python backend, AssemblyScript-flavored types for that one). The runtime seam is reused as-is; only the codegen/prompt half is per-language.
|
||||
|
||||
**2. Implementation package `packages/code-runtime-vm/`** — a new package `@deepseek-ai/dsh-code-runtime-vm`, the `node:vm` reference stub. It type-erases the model's TypeScript via the compiler's `transpileModule` (or sucrase) — the types exist only to guide the model; the runtime is plain JS — then wraps the body in an async IIFE for top-level `await` (Cloudflare's `NodeVMExecutor` does literally `new AsyncFunction("codemode", "return await (${code})()")`), runs it in a `vm.Context` whose globals are a capturing `console` and the SDK namespace objects, awaits the IIFE, and captures the return value, the buffered logs, and any thrown error (as `error: string`). It applies an **output cap** (truncate captured logs) and a **timeout tied to `request.signal`**. These caps limit blast radius; **they are not a security boundary**. node:vm is **not** isolation: withholding `require`/`process` does not contain anything (code escapes via `constructor`/prototype reflection), and per [AGENTS.md](../../../../AGENTS.md) the harness must never hand model output the ambient environment.
|
||||
|
||||
**The unsafe-runtime guard is enforceable, not a README warning.** Because a README caveat is not a control — and AGENTS.md's "never hand model output ambient authority" is a hard rule, not advice — the design makes the danger refuse to run by construction. Two layers:
|
||||
|
||||
- **The runtime declares its trust level.** `CodeRuntime` carries a readonly `safe: boolean` (a `node:vm`-class stub returns `safe = false`; a real isolate/sandboxed-process substrate returns `safe = true`). The `code-runtime-vm` constructor *additionally* requires an explicit opt-in — `new VmCodeRuntime({ unsafe: true })` — and **throws** if that flag is absent, so merely depending on the package and wiring it cannot silently produce a live unsafe runtime; the operator must type the word `unsafe`.
|
||||
- **The consumer refuses to expose `run_code` over an unsafe runtime by default.** When `code-mode` initializes, if `ctx.codeRuntime.safe === false` it does **not** register `run_code` unless the plugin itself is configured with an explicit acknowledgement (e.g. `code-mode` config `allowUnsafeRuntime: true`). Absent that, it logs a typed error and registers nothing — so a real model never reaches an unsandboxed runtime by a single config slip. The refusal path is tested: with the acknowledgement unset and an unsafe runtime, `run_code` is absent (and the wire tool list is unchanged from native); with both opt-ins set, it registers and runs. This keeps the unsafe reference backend usable for tests and trusted local demos while making production misuse take two deliberate, greppable flags rather than one mistake.
|
||||
|
||||
`code-runtime-vm` is therefore documented as **reference / test-only / unsafe-for-untrusted-input**, acceptable in the MVP only because the code runs at harness trust *and* both opt-in flags must be set. Signal handling is best-effort: it aborts in-flight sub-dispatches but cannot reliably interrupt a hot synchronous loop (`while(true){}`) in node:vm — another reason the hardened substrate is deferred, not optional-forever.
|
||||
|
||||
**3. Consumer plugin `packages/code-mode/`** — a new package `@deepseek-ai/dsh-code-mode`, the plugin that wires everything together. It declares `inject = ['tools', 'systemPrompt', 'codeRuntime']` — Cordis throws on access to a service that is not injected, and keeps the plugin inactive until all three exist (the same pattern as `tool-bash`'s `inject = ['tools', 'bash']`), which also gives correct load-ordering relative to `code-runtime`/`code-runtime-vm`. The plugin contributes four things, all through existing seams:
|
||||
|
||||
**3a. Tool presentation — a lazy system-prompt section (the injection seam already exists).** `dsh-system-prompt` already provides the Cordis-idiomatic way for any plugin to inject prompt snippets: `ctx.systemPrompt.section({ name, order, text })`, fiber-scoped and auto-disposed via `ctx.effect()`, where `text` may be a lazy `() => string` re-evaluated at each assembly. No new mechanism is needed or invented. Code Mode registers a lazy section (high `order` so it lands last) whose thunk reads `ctx.tools.schemas()` at assembly time and regenerates the SDK `.d.ts` plus usage instructions from the currently-registered tool set. Because the thunk reads the live registry, coverage of every tool — built-in, MCP, future — is automatic.
|
||||
|
||||
**3b. Wire tool-list enforcement — an `agent/request` listener (the authoritative seam).** The goal "exactly one tool reaches the wire" must be enforced where the wire request is finalized. The loop calls `ctx.systemPrompt.assemble()` first, *then* builds `GenerateOptions` (seeding `tools` from `assembly.tools`), *then* runs the `agent/request` waterfall, *then* calls `ctx.llm.stream()`. A `system-prompt/assemble` listener can only influence the *seed*; `agent/request` is the last seam before the model call, so it is authoritative. The plugin registers an `agent/request` listener that does `const final = await next(); return { ...final, tools: [runCodeSchema] }` — overriding the value *returned by* `next()`, not the inbound argument, so it dominates the cooperative request listeners it wraps. It registers with `prepend: true` to sit at the outer edge of the waterfall chain. One honest caveat, stated in the RFC body: `ctx.llm.stream()` itself runs a further `llm/stream` waterfall before the adapter, so the guarantee is "authoritative within the agent request pipeline," not an absolute wire invariant; if a hard invariant is ever required, a defensive `llm/stream` assertion with a spy adapter covers it in tests.
|
||||
|
||||
**3c. The single tool — `run_code`.** Registered normally in `ctx.tools` with one parameter `{ code: string (required) }`. Because it is an ordinary tool, the unchanged loop dispatches it through the normal path — this is the crux of "zero loop changes." Its `execute(args, exec)`:
|
||||
|
||||
1. Builds the SDK bindings. For each real tool, an async `invoke(callArgs)` that **checks `exec.signal?.aborted` (throwing if set) before and after** calling `ctx.tools.execute({ callId: <deterministic sub-id>, name, arguments: callArgs, agent: exec.agent, signal: exec.signal })`, then maps the resulting `ContentBlock[]` to a simplified `{ output, isError }` (text blocks for the MVP), and emits an observability event. The explicit abort check matters because `ctx.tools.execute()` *catches* thrown tool errors and converts them to `isError` results — without the check, an aborted sub-call would look like ordinary error data and the program would keep running instead of stopping. Sub-dispatch still flows through the `tools/pre-execute`/`tools/post-execute` waterfalls, so permission/sandbox/hook plugins apply to code-mode calls exactly as to native ones.
|
||||
2. Calls `ctx.codeRuntime.run({ code: args.code, sdk: bindings, signal: exec.signal })`.
|
||||
3. Surfaces the outcome. A *successful* run returns `[{ type: 'text', text: <console logs + return value> }]`. A *runtime-error* result cannot be reported by returning content, because a normal `ToolDefinition.execute()` returns only `Promise<ContentBlock[]>` and `ToolRegistry.execute()` hardcodes `isError: false` on any successful return — `isError: true` arises only from the registry's catch path. So on an error result the tool **throws a `CodeRunError extends HarnessError`** (`HarnessError` is exported from `dsh-llm`; the registry catch turns any throw into `isError: true` with the message as text, and a `HarnessError` additionally carries structured `{ name, code }`). An alternative — registering `run_code` handling as a `tools/execute` listener that returns a full `ToolExecutionResult` and can set `isError` directly — is noted; the throw is simpler and preferred.
|
||||
|
||||
**3d. Result discipline — what the model receives.** The model gets back **only the captured console output and/or the program's return value** (the model chooses which to surface). Intermediate sub-call results are **never** returned to the model. This is the core context-saving benefit: the agent curates its own output, exactly as a script's stdout curates a pipeline's intermediate state.
|
||||
|
||||
**Sub-call CallIds.** Real tool calls dispatched from inside `run_code` need ids, but `CallId` is normally provider-issued (a branded string for correlating a call with its result — only brand-wrapped via `CallId()`, with no generator and no documented session-global-uniqueness guarantee). The plugin mints deterministic sub-ids scoped to the parent: `` `${exec.callId}:code:${n}` `` with a per-run counter `n`. These are unique within one `run_code` run (assuming the parent `callId` is unique, which the provider guarantees per turn); the `code/dispatch` event additionally carries the session log's `seq` so the UI and persistence can order and disambiguate globally without relying on the id alone. `ToolExecution.agent` is optional; the normal loop always supplies it (and with it `exec.agent.session`, the log `code/dispatch` appends to). A `run_code` execution arriving without `exec.agent` still runs (sub-calls propagate `agent: undefined`, exactly as the loop's own contract allows) but **skips session-log observability** — with no session to append to, those direct runs are simply not logged.
|
||||
|
||||
**Observability without context cost.** Each sub-dispatch emits a session event **declared by the `dsh-code-mode` plugin itself** via `SessionEventMap` declaration merging (the map is merge-extensible precisely so plugins can add events without touching `dsh-session`). Shape: `code/dispatch` with `{ parentCallId, subCallId, name, arguments (or redacted), isError, summary }`, ordered by the session log's own `seq`. `deriveMessages()` does **not** translate it into a model message — an unknown event type falls through its `default`, per the merge-extensible-union convention — so the UI and persistence ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)) can render every sub-call while the model's context only ever receives the single `run_code` tool-result. Because the event lives in the plugin, this adds no core change.
|
||||
|
||||
**SDK codegen.** A pure `jsonSchemaToTs(schema)` in `code-mode` maps the JSON-schema subset the `defineTool` DSL produces (object/string/number/boolean/array, `properties`, `required[]`, `enum` → string-literal union, nested objects, array `items`) to a TS type literal. It is **total**: any unsupported construct (`$ref`, `oneOf`/`anyOf`, `integer`, `null`, `additionalProperties`, or any raw MCP shape it does not recognize) degrades to `unknown` without throwing — it never crashes codegen. Typing is best-effort, not a guarantee, because MCP tools accept arbitrary JSON Schema and `ToolSchema.parameters` is typed only as `Record<string, unknown>`. Because `ToolSchema.name` is an arbitrary string (not necessarily a valid TS identifier), the SDK is generated as a **namespace with quoted access** (e.g. `tools["some-mcp-tool"](args)`) plus safe camelCase aliases where the name is a clean identifier; alias collisions and TS reserved words fall back to quoted-only access (no duplicate alias emitted). This mirrors Cloudflare's `sanitizeToolName`. `run_code` itself is filtered out of the SDK. The MVP surfaces text content only; image and other block types in sub-results are deferred (noted as a limitation).
|
||||
|
||||
**Concurrency — serialized by default (the binding must enforce it).** The SDK functions are async, so a model writing `await Promise.all([tools.a(...), tools.b(...)])` would *start both* immediately, and each would call `ctx.tools.execute` right away — i.e. the binding shape makes concurrent dispatch the **default**, not an opt-in. Because the tool contract carries **no concurrency-safety metadata today** (parallel tool execution and a concurrency-safety hint are an open TODO in both `dsh-tools` and [docs/architecture.md](../../../architecture.md): "phase 1 executes tool calls sequentially"), concurrent dispatch through a not-yet-hardened tool may race. So a prose "may serialize" is not sufficient. **Decision: the MVP SDK bindings enforce serialization** — each `run_code` invocation owns a per-run dispatch queue, and every `invoke()` chains onto it (`tail = tail.then(() => ctx.tools.execute(...))`), so even `Promise.all` over SDK calls executes them one at a time in submission order. This is a hard acceptance criterion, with a test that issues `Promise.all([...])` from a program and asserts the underlying `ctx.tools.execute` calls did **not** overlap (e.g. a probe tool records enter/exit and the test asserts no interleaving). The `.d.ts` may *describe* the model-visible functions as async (they are), but the implementation guarantees serial execution. Lifting serialization is deferred: only once a tool can declare itself read-only / concurrency-safe does the binding allow those specific tools to overlap. The same per-run queue is where the before/after abort checks (§3c) live, so an aborted run drains no further queued dispatches.
|
||||
|
||||
**Tool visibility tiers (design intentionally skipped).** A natural extension is to mark each tool with a *visibility tier*: some tools "direct-call eligible" (still offered as native wire tools alongside `run_code`), some "code-mode only" (reachable solely from within a `run_code` program, never on the wire), and the default "both." This would let a deployment keep a few high-frequency or approval-gated tools as direct calls while routing the long tail through Code Mode, or hide composition-only primitives from the native surface entirely. This RFC notes the possibility but **intentionally skips the detailed design** — the per-tool metadata, how it interacts with the `agent/request` enforcement in 3b, and the presentation split in 3a are left to a follow-up. The MVP is the simple two-state model: Code Mode on (everything via `run_code`) or off (everything native).
|
||||
|
||||
**Optionality / toggle.** Loading the `code-mode` plugin enables Code Mode for that context; not loading it leaves today's native tool-calling untouched. The two are mutually exclusive within one ctx, because Code Mode rewrites the wire tool list down to `[run_code]`. Per-agent selection via ctx forks, and the visibility tiers above, are future work; the MVP toggle is plugin presence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Result elision / summarization over native tool-calling (the narrower route).** The Problem has two halves — context bloat (every intermediate `tool-result` re-enters context) and serial composition (one tool call per round-trip). The context-bloat half can be addressed *without* any code-execution runtime: keep provider tool-calling exactly as it is, and add a plugin on the `agent/request` waterfall (or a compaction pass akin to [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md)) that elides or summarizes older `tool-result` blocks before they re-enter the model's context — drop them past a window, replace large payloads with a digest, or keep only the blocks the model still references. This is strictly less invasive than Code Mode: no new runtime seam, no model-written programs, no new safety surface. It is the right tool if context growth is the only pain.
|
||||
|
||||
It is insufficient for the **composition / round-trip** half, which is the decisive reason this RFC does not stop there. Elision still pays one model round-trip per tool call: a loop over N items is N turns, a branch on an intermediate value is a turn to fetch then a turn to act, and post-processing (filter, join, reduce) either happens in the model's head over full payloads or not at all. Code Mode collapses all of that into one program — the loop, the branch, the join run in the runtime, and only the curated result returns. Elision also cannot express fan-out or data-dependent control flow; it only shrinks what comes back. So the two are complementary, not competing: elision could even layer *under* Code Mode for the residual native-tool paths. The RFC chooses Code Mode because the round-trip/composition cost is the larger structural limit, and accepts the new code-execution surface as the price — which is exactly why the execution substrate is gated behind the enforceable safety guard (§2) and the hardened backend is a hard prerequisite for untrusted use.
|
||||
|
||||
**Why not change the loop to dispatch native tool calls in parallel instead?** That is the other obvious answer to the round-trip cost, and it remains valid future work (it is the open `dsh-tools`/architecture.md TODO). But it is a core-loop change requiring the same concurrency-safety metadata Code Mode defers, and it still does not give the model *composition* (branch/loop/post-process between calls) — only parallelism of independent calls the model already decided to make in one step. Code Mode delivers composition with zero core change; parallel native dispatch and Code Mode can coexist later.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Scaffold the interface package `packages/code-runtime/` per [the cookbook](../../../cookbook/adding-a-package.md): abstract `CodeRuntime extends Service` (`super(ctx, 'codeRuntime')`) with a readonly `safe: boolean`, the `declare module 'cordis'` ctx key, the `CodeRunRequest`/`CodeRunResult`/`SdkBinding` vocabulary, method contracts documented in JSDoc (what `run` captures, abort semantics, that an error is a result field not a throw, what `safe` means). HMR-safety test (dispose the contributing fiber, assert `ctx.codeRuntime` is gone).
|
||||
2. Scaffold the implementation package `packages/code-runtime-vm/`: the node:vm stub — `safe = false`, a constructor that **throws unless given `{ unsafe: true }`**, transpile/type-erase, async-IIFE wrap, capturing `console`, SDK globals, return-value/logs/error capture, output cap, signal-tied timeout. Tests for output capture, return value, error-as-field, abort, the constructor refusal without `unsafe`, and a README documenting the "not a sandbox, trusted-only" caveat prominently.
|
||||
3. Scaffold the consumer plugin `packages/code-mode/`: `jsonSchemaToTs` codegen with namespace/quoted-access + alias handling (unit tests, including non-identifier MCP names and unsupported-shape → `unknown`); the registered lazy `ctx.systemPrompt.section()` carrying the SDK `.d.ts`; the `agent/request` listener (`prepend: true`) collapsing `request.tools` to `[run_code]` after `await next()`; the **unsafe-runtime gate** (refuse to register `run_code` when `ctx.codeRuntime.safe === false` unless `allowUnsafeRuntime` is set); the `run_code` tool with the dispatch bridge (per-run serialization queue, deterministic sub-call ids, before/after abort checks, `CodeRunError` on error results); and the `code/dispatch` event declared here via `SessionEventMap` merge. Declare `inject = ['tools', 'systemPrompt', 'codeRuntime']`.
|
||||
4. Tests: HMR-safety (dispose removes the tool, the section, and the listener); a waterfall test that the wire tool list is exactly `[run_code]` (spy adapter, asserting via `agent/request` and optionally `llm/stream`); an integration test that a program calling two tools returns only its printed/returned output (verify the world, not the self-report); a **serialization test** that `Promise.all([...])` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (a probe tool records enter/exit; assert no interleaving); `deriveMessages()` ignores `code/dispatch`; abort mid-program stops further dispatches; `CodeRunError` surfaces as `isError: true`; and the **unsafe-runtime refusal test** (§3, the VM-guard): with the unsafe flag unset, a non-mock agent's `run_code` is refused; with it set, the program runs.
|
||||
5. Wire an example: `examples/coding-agent-code-mode` (or a config flag on the existing example) loading the trio. Running it against the node:vm stub requires both opt-ins (`VmCodeRuntime({ unsafe: true })` and `code-mode`'s `allowUnsafeRuntime`); the example sets them explicitly and comments why, or uses a mock model — a real model never reaches the unsandboxed stub without those deliberate flags. Add a `pnpm run demo:*` entry.
|
||||
6. Docs: update [docs/architecture.md](../../../architecture.md) (a `ctx.codeRuntime` row in the service map, a Code Mode note under the tool pipeline / capability seams sections); add a [cookbook](../../../cookbook) note on writing a `CodeRuntime` backend; and **file the follow-up RFC for the hardened execution substrate** (the isolate/sandboxed-process design, the additional-language backends sketched in §1 — AssemblyScript/WASM, Python — with their per-language SDK generators, plus the tool-visibility-tier design skipped here). On landing, move this file to `implemented/` and update its row in [the RFC index](../../README.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- The three packages exist and pass their suites: `dsh-code-runtime` (the abstract seam), `dsh-code-runtime-vm` (the reference stub whose constructor throws without `{ unsafe: true }`), and `dsh-code-mode` (SDK codegen, the lazy prompt section, the `agent/request` collapse, the `run_code` tool).
|
||||
- The wire tool list is exactly `[run_code]` under the plugin (spy-adapter test); the generated SDK covers every registered tool, with non-identifier names reachable via quoted access.
|
||||
- A program calling two tools returns only its curated output; `code/dispatch` events land in the session log and never enter derived history.
|
||||
- `Promise.all` over SDK calls does not overlap the underlying `ctx.tools.execute` invocations (the per-run serialization queue holds); an abort stops further dispatches.
|
||||
- With `allowUnsafeRuntime` unset over an unsafe runtime, `run_code` is not registered and the wire tool list is unchanged from native.
|
||||
|
||||
## Risks
|
||||
|
||||
node:vm is not a sandbox. This is the single biggest caveat. Withholding `require`/`process` is not a boundary; the MVP runs at harness trust only; the hardened substrate is a hard prerequisite before any untrusted use and is the explicit subject of a follow-up RFC. The guard is enforceable, not just documented: the runtime exposes `safe: boolean`, the VM stub throws unless constructed with `{ unsafe: true }`, and `code-mode` refuses to register `run_code` over an unsafe runtime unless separately acknowledged (`allowUnsafeRuntime`) — production misuse requires two deliberate, greppable flags, and the refusal path is tested.
|
||||
|
||||
Wrong seam would leak tools. If the wire tool list were enforced only in `system-prompt/assemble`, a later `agent/request` listener could re-add tools. Mitigation: enforce `request.tools = [run_code]` in the `agent/request` waterfall (the authoritative seam, run last before `llm.stream()`) with `prepend: true`, and assert exactly one wire tool in tests. The residual `llm/stream` caveat is documented, not hidden.
|
||||
|
||||
Concurrency before the contract supports it. The binding shape makes concurrent dispatch the default, and the tool contract has no concurrency-safety metadata yet, so unguarded `Promise.all` over SDK calls could race a not-yet-hardened tool. Mitigation: the MVP bindings enforce a per-run serialization queue (every `invoke` chains onto the previous), with a test asserting `Promise.all` from a program does not overlap the underlying `ctx.tools.execute` calls. Per-tool parallelism is unlocked only once a tool can declare itself concurrency-safe.
|
||||
|
||||
Two presentation modes to keep coherent. A tool added later must work in both native and Code Mode. Mitigation: both the codegen thunk and the `agent/request` listener read `ctx.tools.schemas()`, so coverage is automatic; a test asserts every registered schema produces valid `.d.ts`, including non-identifier MCP names via quoted access.
|
||||
|
||||
Type-erased runtime is not type-checked. The model can write code that type-checks against the advisory `.d.ts` but throws at runtime, and MCP-schema typing is best-effort. Mitigation: errors are captured as `CodeRunResult.error` and surfaced so the model can self-correct; the `.d.ts` is explicitly advisory.
|
||||
|
||||
Lost observability of sub-calls. Routing everything through one `run_code` result hides the individual calls from the model — and could hide them from operators too. Mitigation: the plugin-declared `code/dispatch` event keeps every sub-call in the session log and UI without polluting model context.
|
||||
|
||||
Abort granularity. node:vm cannot reliably interrupt hot synchronous code, and `ctx.tools.execute()` converts thrown aborts into `isError` data. Mitigation: the SDK bindings check `signal.aborted` and throw before/after each dispatch so an aborted sub-call stops the program; the vm stub wraps the run in a signal-tied timeout; the hardened substrate addresses the hot-loop case.
|
||||
|
||||
Unsafe example wiring. A demo running a real model through the node:vm stub would hand model output ambient authority. Mitigation: examples are mock-model or explicitly marked unsafe; `code-runtime-vm` is labeled reference/test-only.
|
||||
|
||||
Non-text sub-results dropped in the MVP. Image and other block types from sub-calls are not surfaced into the program yet. Mitigation: noted as a known limitation; block-type handling deferred.
|
||||
@@ -0,0 +1,87 @@
|
||||
# RFC: Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)
|
||||
|
||||
Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The subagent seam ([the seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md)) hosts multiple named providers on `ctx.subagents`, and the ACP backend ([the ACP backend RFC](../../implemented/feature/2026-06-22-acp-subagent-backend.md)) proved the seam generalizes across a process boundary; its Future-providers section explicitly named the Codex app-server and the Claude Code Agent SDK as mechanically similar siblings. Those two are the engines actually worth delegating to today: a harness turn should be able to hand a self-contained task to a real Claude Code or a real Codex — a separate product with its own model, tools, and sandbox — and get back one final answer, without the parent deployment leaking its secrets into the child or the child's behavior silently depending on whatever `~/.claude` / `~/.codex` state exists on the host machine.
|
||||
|
||||
## Proposal
|
||||
|
||||
Two sibling provider packages, structural variants of the ACP backend, plus one extraction:
|
||||
|
||||
- `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter.
|
||||
- `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package.
|
||||
- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change.
|
||||
|
||||
Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk.
|
||||
|
||||
## Verified interface facts (pinned versions)
|
||||
|
||||
Both integration surfaces were verified against pinned implementations before this proposal — types and bundled source read, keyless spikes run — not from vendor docs alone. The pins are the verification baseline, not a runtime contract: the backends perform no runtime version probe (no `codex --version` gate, no SDK version sniffing). Compatibility is enforced at development time — every dependency bump re-runs the keyless suites against the real load path — and at runtime by failing loudly: a protocol-level surprise settles `error` via `onError`, never a silent misbehavior.
|
||||
|
||||
**`@anthropic-ai/claude-agent-sdk` 0.3.202.** `options.env` REPLACES the child environment (no merge with `process.env`), which is exactly what the scrub needs. `settingSources` defaults to loading ALL filesystem settings — isolation requires explicitly passing `[]`. Result subtypes are `success` | `error_during_execution` | `error_max_turns` | `error_max_budget_usd` | `error_max_structured_output_retries`. On abort the SDK escalates the CLI child itself: stdin EOF immediately, SIGTERM ~2s later if the child ignores it (observed; no leftover processes) — no bespoke kill fallback needed. `outputFormat: {type: 'json_schema'}` and an `agents` option exist, giving future landing points for the seam's `outputSchema` capability and named subagent types; both are out of scope here.
|
||||
|
||||
**codex CLI 0.142.5, `codex app-server` (v2 vocabulary).** LF-delimited JSON, JSON-RPC 2.0 shapes with the `"jsonrpc"` header omitted.
|
||||
|
||||
- Lifecycle: `initialize{clientInfo}` + `initialized` → `thread/start` (accepts `cwd`, `model`, `sandbox`, `approvalPolicy`, `ephemeral`; succeeds unauthenticated) → `turn/start{threadId, input:[{type:'text',text}]}` returns an `inProgress` turn immediately; the terminal signal is the `turn/completed` notification carrying `Turn{status: completed|interrupted|failed|inProgress, error}`.
|
||||
- Approvals are server-initiated requests — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request` — answered with `accept`/`decline`-family decisions.
|
||||
- Auth: `account/login/start{type:'apiKey', apiKey}` is a first-class RPC and `account/read` reports `requiresOpenaiAuth` — and an unauthenticated `turn/start` does NOT fail fast (it hangs in retry), so the backend MUST pre-check auth and settle `error` loudly instead of waiting on the turn.
|
||||
- Isolation: `CODEX_HOME` redirection is honored (the `initialize` response echoes it, so tests can assert isolation), and `ephemeral: true` threads leave no session files at all.
|
||||
|
||||
## Isolation and credentials
|
||||
|
||||
Deployments authenticate with API keys only, and the child must not see the host user's Claude Code / Codex configuration: behavior has to be a function of `cordis.yml` alone. Each run gets a fresh `mkdtemp` config dir — `CLAUDE_CONFIG_DIR` for Claude Code (paired with an explicit `settingSources: []`), `CODEX_HOME` for Codex — removed best-effort on dispose; a config field can pin a persistent dir instead. The child env reuses the ACP backend's `buildChildEnv` semantics verbatim via the extraction: the ambient env is forwarded MINUS credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `config.env` layered on top — so `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive and the CLIs run normally, while only credential-shaped ambient vars are scrubbed (`ANTHROPIC_API_KEY` enters explicitly through `config.env` for Claude Code), and the Codex key travels via the `account/login/start` RPC into the isolated `CODEX_HOME` rather than a hand-written `auth.json`.
|
||||
|
||||
## Permission and approval policy
|
||||
|
||||
Instead of collapsing to ACP's single `permission: allow|reject` knob, each backend exposes its engine's native vocabulary as config, with conservative defaults: Claude Code gets `permissionMode` (default `default`) plus `permission: allow|reject` (default `reject`) as the `canUseTool` auto-answer for whatever falls through; Codex gets `sandboxMode` (default `read-only`) and `approvalPolicy` (default `never`) plus the same `permission` fallback for approval requests that still arrive. Defaults are deliberately do-no-harm (the out-of-box child cannot write files); examples demonstrate opening up (`acceptEdits` / `workspace-write`). The mechanical rule: EVERY server-initiated request is settled programmatically and promptly — the enumerated approval/user-input/elicitation requests by the configured policy, an unknown request method with a JSON-RPC method-not-found error response (never left pending), unknown notifications consumed — so no child request can wedge a turn waiting on an answer that will never come. Prompts never reach a human in this cut, matching ACP.
|
||||
|
||||
## StopReason mapping
|
||||
|
||||
Claude Code: `success` → `completed`; `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries` → `error` (aligning with the ACP call on `max_turn_requests`: an unfinished task is not success); generator abort → `aborted`; anything unknown → `error`. Codex: `Turn.status` `completed` → `completed`; `interrupted` → `aborted`; `failed` with `codexErrorInfo: 'contextWindowExceeded'` → `max-tokens`, any other `failed` → `error`; transport/spawn/auth-precheck failure → `error` (or `aborted` if cancel was requested). In both, `cancel()` is the ACP shape: flag + abort/interrupt + a cancel-settled race arm so an uncooperative child cannot stall the result.
|
||||
|
||||
Liveness posture, stated explicitly: teardown timing is config, turn duration is not. Both backends take the dispose ladder's grace periods as defaulted validated config fields (the ACP backend's `disposeEofGraceMs`/`disposeGraceMs` shape, carried by the extraction), but there is deliberately NO turn-duration or startup timeout — matching ACP, liveness during a turn belongs to the caller via `cancel()`/the abort signal, a subagent turn is legitimately minutes long, and the Codex auth precheck removes the one verified guaranteed-hang; a deployment wanting a wall-clock bound cancels from the parent.
|
||||
|
||||
## Testing
|
||||
|
||||
Named at every tier per the root AGENTS.md rule, and de-risked up front:
|
||||
|
||||
- **Keyless unit/integration**, mirroring the ACP spec list per backend (round-trip and output accumulation, every stop mapping, both cancel paths, already-aborted, permission auto-answer under both policies, unknown-message tolerance, bad-command spawn failure, HMR provider cleanup, export shape, isolation assertions on child env and temp-dir removal; Codex adds the auth-precheck failure path). Claude Code's harness is a scripted fake `claude` executable behind `pathToClaudeCodeExecutable` driven by the REAL SDK — a spike already passed end-to-end keyless in 24ms (the fake CLI answers one `control_request/initialize` and speaks plain stream-json, ~40 lines). Codex's harness is a scripted mock app-server subprocess speaking the verified wire protocol, the `mock-acp-server.ts` shape.
|
||||
- **With-key e2e** per backend: the real engine does real file work verified on disk, under a pinned opened-up config so acceptance and the do-no-harm defaults don't collide — `permissionMode: 'acceptEdits'` for Claude Code, `sandboxMode: 'workspace-write'` + `approvalPolicy: 'never'` for Codex; self-skips report exactly what is missing (binary vs key). CI has no secrets, so these run locally per the with-key policy.
|
||||
- **Snapshot**: deferred as `TODO(claude-code-subagent-replay)` / `TODO(codex-subagent-replay)` — the same distinct replay shape the ACP backend deferred ([the per-session replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md)); the keyless suites carry deterministic coverage meanwhile.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Why not the official `@openai/codex-sdk` instead of a hand-rolled client?
|
||||
|
||||
The dispose ladder and env scrub require owning the child process (spawn args, env, signals, exit await); the SDK hides the process. The wire format is trivial to frame (LF JSON), the shapes are generatable per pinned version (`codex app-server generate-json-schema`), and the repo precedent (`hook-protocol`) is to own thin protocol cores rather than wrap someone's runtime. The SDK would save protocol-evolution maintenance but costs the exact control this backend exists to have.
|
||||
|
||||
### Why not a model-visible `subagent_type` parameter (one Task-style tool)?
|
||||
|
||||
Claude Code's own Task tool puts the subagent type in the model-facing schema, selecting a prompt-plus-toolset persona. Here the choice is between EXECUTION ENGINES, and only the deployer knows which engines have credentials configured — so selection stays deployment config, preserving `dsh-tool-subagent`'s documented one-provider-per-tool contract. A persona-style type selector would be a separate RFC against the tool, not the backends.
|
||||
|
||||
### Why not login-state credentials and the user's own config?
|
||||
|
||||
Inheriting `~/.claude` / `~/.codex` (subscription login, user settings, skills, MCP servers) would make child behavior depend on host-machine state and punch an implicit exception through the "credentials enter explicitly via `config.env`, never ambiently" rule the ACP backend and bash executor established. API-key-only plus forced config-dir isolation keeps runs reproducible; deployments wanting shared state can point the config-dir field at a persistent directory deliberately.
|
||||
|
||||
### Why not a driver-injection seam for the Claude Code keyless tests?
|
||||
|
||||
Injecting a fake `query()` would mock our own boundary and leave the real SDK load path untested (the real-over-mock policy in docs/testing.md). The risk that justified considering it — the SDK↔CLI stream-json control protocol being internal — was retired by the spike: the fake-CLI harness works against the real pinned SDK today. If an SDK upgrade breaks the mock, the keyless suite fails the upgrade PR, which is the gate working.
|
||||
|
||||
### Why not ACP adapters (e.g. `claude-code-acp`) reusing the existing backend?
|
||||
|
||||
Community shims wrap both engines in ACP, which would make them "just config" on `dsh-subagent-acp`. But that inserts an unofficial third-party layer between the harness and the engine, erases the native control surfaces this RFC exposes (permissionMode, sandboxMode/approvalPolicy, config-dir isolation, apiKey RPC), and trades first-party protocol stability for a shim's release cadence. First-party surfaces — the Agent SDK and the app-server — are the supported integration points.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
On a machine with both engines and keys configured: a REPL-driven model completes one real file task through `subagent_claude_code` and one through `subagent_codex`, the tool result being the child's final answer, with only `tool/call` + `tool/result` in the parent session log. Keyless suites pass at 100% per-file coverage in a credential-less environment, asserting isolation (scrubbed child env, no temp config dirs left after dispose) and that child behavior is unchanged by the presence or absence of `~/.claude` / `~/.codex`. Cancelling a parent turn quiesces both backends in bounded time with no leftover child processes. E2e suites self-skip cleanly, naming the missing prerequisite.
|
||||
|
||||
## Risks
|
||||
|
||||
- `codex app-server` is CLI-flagged experimental and its v1/v2 vocabularies coexist; the client pins 0.142.5, implements v2 only, and consumes unknown methods/notifications without crashing, but a future codex bump can still force rework (regenerate schemas and re-run the keyless suite on every bump — the development-time enforcement behind the no-runtime-version-probe stance above).
|
||||
- The Claude Code fake-CLI mock rides an internal protocol: any SDK upgrade must go through the keyless suite, and a breaking control-protocol change means reworking the mock (fallback: the driver-injection seam rejected above becomes the escape hatch).
|
||||
- The SDK's optionalDependencies weigh ~280MB per platform — accepted, and confined to the one backend package.
|
||||
- The SDK's SIGKILL branch beyond EOF→SIGTERM was not observed and is trusted; e2e keeps a no-leftover-process assertion.
|
||||
- Codex is a deployment prerequisite (no npm-bundled binary); a missing or incompatible binary surfaces as a loud spawn/protocol `error`, not a version probe.
|
||||
- Every run pays a fresh child process and only the final answer surfaces — thoughts, tool cards, and usage are consumed and dropped; pooling, intermediate-progress surfacing, `sendMessage`/`resume`, `outputSchema` via the SDK's `outputFormat`, and named subagent types via the SDK's `agents` option are all deliberate deferrals.
|
||||
+3
-3
@@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning
|
||||
- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Excessive tests are welcome — err toward covering edge cases, error paths, event ordering, and concurrency races; review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`).
|
||||
- **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped.
|
||||
- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)).
|
||||
- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Re-record with `pnpm run test:snapshot:record`; reviewing the golden diff is part of the review.
|
||||
- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Re-record with `pnpm run test:snapshot:record`; reviewing the golden diff is part of the review. System-prompt/tool-schema content is pinned by ONE scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
|
||||
## The with-key policy: inference is cheap here
|
||||
|
||||
@@ -25,9 +25,9 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword
|
||||
|
||||
- A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)).
|
||||
- A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert.
|
||||
- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). Keep the built-bin smokes green (`packages/ui/*/tests/built-bin.e2e.ts`), and assert a genuinely-missing config exits non-zero.
|
||||
- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.js`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero.
|
||||
- An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)).
|
||||
|
||||
## When a snapshot test is required
|
||||
|
||||
Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario under `examples/acp-agent/tests/snapshots/` (or states in the PR why none applies). New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise.
|
||||
Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario in the owning example's snapshot suite (`examples/<name>/tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/acp-agent` is the primary suite), or states in the PR why none applies. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise.
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
# Tool Schema Catalog
|
||||
|
||||
Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](../cordis-catalog/events.md) & [services](../cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](../core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.
|
||||
Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.
|
||||
|
||||
This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](../rfc/implemented/process/2026-07-02-tool-schema-catalog.md).
|
||||
This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).
|
||||
|
||||
Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope.
|
||||
|
||||
@@ -15,13 +15,85 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
|
||||
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
|
||||
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
|
||||
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
|
||||
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
|
||||
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
|
||||
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
|
||||
|
||||
## `@deepseek-ai/dsh-tool-ask-user`
|
||||
|
||||
### `ask_user_question`
|
||||
|
||||
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"description": "Questions to ask the user before continuing.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable id for this question; echoed in the answer."
|
||||
},
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The specific question to ask the user."
|
||||
},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Short user-facing option label."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One sentence explaining the tradeoff or impact."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label"
|
||||
]
|
||||
}
|
||||
},
|
||||
"multi_select": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user may select more than one option. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"question"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"questions"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)
|
||||
|
||||
ask_user_question pauses the tool call until the active UI provider returns a human answer.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-bash`
|
||||
|
||||
### `bash`
|
||||
@@ -60,7 +132,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts)
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
### `bash_kill`
|
||||
|
||||
@@ -81,7 +153,7 @@ Ask the executor to kill a running background bash task by task id.
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts)
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
### `bash_output`
|
||||
|
||||
@@ -102,10 +174,82 @@ Read new output from a background bash task started with `bash` + `run_in_backgr
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../../packages/bash/tool-bash/src/index.ts)
|
||||
Source: [`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts)
|
||||
|
||||
The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-cordis`
|
||||
|
||||
### `cordis_inspect`
|
||||
|
||||
Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"what": {
|
||||
"type": "string",
|
||||
"description": "Limit the report to one section. Omit for all sections.",
|
||||
"enum": [
|
||||
"services",
|
||||
"plugins",
|
||||
"tools",
|
||||
"dynamic",
|
||||
"api",
|
||||
"events"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
### `cordis_mount`
|
||||
|
||||
Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:<id>]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Body of an async JS function; must `return` the plugin to mount."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
### `cordis_unmount`
|
||||
|
||||
Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts)
|
||||
|
||||
Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes.
|
||||
|
||||
## `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
### `edit`
|
||||
@@ -141,7 +285,7 @@ Edit an existing UTF-8 text file by replacing literal text.
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
### `read`
|
||||
|
||||
@@ -170,7 +314,7 @@ Read a UTF-8 text file and return line-numbered content.
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
### `write`
|
||||
|
||||
@@ -196,7 +340,7 @@ Create or fully replace a UTF-8 text file.
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../../packages/fs/tool-fs/src/index.ts)
|
||||
Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts)
|
||||
|
||||
The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.
|
||||
|
||||
@@ -221,7 +365,7 @@ Load the full instructions for one available skill by name. Use this when the cu
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/core/tool-skill/src/index.ts`](../../packages/core/tool-skill/src/index.ts)
|
||||
Source: [`packages/core/tool-skill/src/index.ts`](../packages/core/tool-skill/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-subagent`
|
||||
|
||||
@@ -249,7 +393,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts`](../../packages/subagent/tool-subagent/src/index.ts)
|
||||
Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
|
||||
|
||||
The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.
|
||||
|
||||
@@ -296,7 +440,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/todo/tool-todo/src/index.ts`](../../packages/todo/tool-todo/src/index.ts)
|
||||
Source: [`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts)
|
||||
|
||||
todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan.
|
||||
|
||||
@@ -313,10 +457,6 @@ Fetch the content of a specific HTTP(S) URL and return it decoded to text.
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The HTTP(S) URL to fetch."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"description": "Optional fetch timeout in milliseconds (capped by the provider)."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -325,7 +465,7 @@ Fetch the content of a specific HTTP(S) URL and return it decoded to text.
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/tool-web/src/index.ts`](../../packages/web/tool-web/src/index.ts)
|
||||
Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
### `web_search`
|
||||
|
||||
@@ -346,6 +486,6 @@ Search the web for current information. Returns an optional summary answer and a
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/web/tool-web/src/index.ts`](../../packages/web/tool-web/src/index.ts)
|
||||
Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps.
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# Tool Execution Pipeline
|
||||
|
||||
This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute` and `tools/post-execute` waterfalls.
|
||||
This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -12,6 +12,7 @@ flowchart TD
|
||||
presentCall["UI pending card<br/>presentCall(args)"]
|
||||
pre["<code>tools/pre-execute</code> waterfall<br/>hooks, permission, sandbox"]
|
||||
denied["deny or ask<br/>tool body skipped"]
|
||||
around["<code>tools/execute</code> waterfall<br/>timeout, retry, metrics (around dispatch)"]
|
||||
toolBody["Registered tool execute() body"]
|
||||
fsGate["<code>fs/write-intent</code> or <code>fs/edit-intent</code><br/>tool-fs mutations only"]
|
||||
owned["Tool-owned session events<br/><code>todo/write</code>, <code>fs/observed</code>, <code>hook/invoked</code>, <code>hook/result</code>"]
|
||||
@@ -22,18 +23,20 @@ flowchart TD
|
||||
model --> toolCall
|
||||
toolCall --> presentCall
|
||||
toolCall --> pre
|
||||
pre -->|allow| toolBody
|
||||
pre -->|allow| around
|
||||
around --> toolBody
|
||||
pre -->|deny or ask| denied
|
||||
denied --> post
|
||||
toolBody --> fsGate
|
||||
fsGate --> toolBody
|
||||
toolBody --> owned
|
||||
toolBody --> post
|
||||
toolBody --> around
|
||||
around --> post
|
||||
post --> context
|
||||
post --> toolResult
|
||||
toolResult --> presentResult
|
||||
```
|
||||
|
||||
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.
|
||||
Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and future permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.
|
||||
|
||||
Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs.
|
||||
@@ -21,6 +21,8 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
'**/lib/**',
|
||||
'**/node_modules/**',
|
||||
'**/.sessions/**',
|
||||
'**/.doc-typecheck-*/**',
|
||||
'vendor/**', // vendored source keeps upstream style and idioms
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
|
||||
@@ -21,6 +21,7 @@ A keyless smoke that spawns the example from a temp cwd must set `TSX_TSCONFIG_P
|
||||
|---|---|---|
|
||||
| `echo-agent` | `tests/echo.e2e.ts` — boots the real `cordis.yml`, drives the echo tool round-trip and the direct canned reply | **N/A — keyless by nature** (the `mock-echo` model has no real provider) |
|
||||
| `coding-agent` | `tests/keyless-smoke.e2e.ts` — boots the full real tree (dummy key, no prompt → no model call), asserts banner + clean exit | `tests/{full-loop,coding-task,resume,compaction,todo-write}.e2e.ts` — real model + real bash + real todo_write, world-verified |
|
||||
| `cordis-agent` | `tests/keyless-smoke.e2e.ts` — boots the real tree incl. `@deepseek-ai/dsh-tool-cordis` by package name; the tool logic is unit-tested in `packages/cordis/tool-cordis` | `tests/cordis-tools.e2e.ts` — real model mounts a listener (tagged line fires), builds+calls its own tool, composes two mounts via provide/inject |
|
||||
| `acp-agent` | `pnpm run test:snapshot` — boots the real ACP subprocess and replays a recorded session keyless (incl. the hook matrix: a scenario per hook point × outcome for BOTH the Claude and Codex bridges — block, deny, ask, context-fold, force-continue); `tests/acp.e2e.ts` also asserts stdout purity without a key | `tests/acp.e2e.ts` — real ACP prompt, verifies a file the agent wrote; `tests/hooks.e2e.ts` — a real `PreToolUse` hook blocks bash, verifies the file is NOT written |
|
||||
|
||||
See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design.
|
||||
@@ -19,6 +19,12 @@ A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + th
|
||||
|
||||
Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
|
||||
|
||||
## cordis-agent
|
||||
|
||||
The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on.
|
||||
|
||||
Run with: `pnpm run demo:cordis` (needs `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset RFC](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats.
|
||||
|
||||
## acp-agent
|
||||
|
||||
An agent demo exposed as an **Agent Client Protocol (ACP)** server over JSON-RPC stdio, via the [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent) app — drive it from Zed or any other ACP client. Also the home of the keyless snapshot tests.
|
||||
|
||||
@@ -6,7 +6,7 @@ The DeepSeek Harness SDK agent demo exposed as an **Agent Client Protocol (ACP)*
|
||||
pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env)
|
||||
```
|
||||
|
||||
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, and the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC.
|
||||
This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent) app (which bundles the [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) spine, JSONL session persistence, and the `@deepseek-ai/dsh-acp` bridge — with **no pre-created agents**, since ACP `session/new` creates them on demand), the swappable DeepSeek, bash, and filesystem backends, the model-facing `read`/`write`/`edit`/`subagent`/`subagent_fork`/`todo_write` tool entries, and the advisory `repeat-tool-guard` loop-hygiene plugin. The app package bakes in the no-stdout-logger cluster, so a leaf has no logger entry to get wrong by default — keeping stdout pure for JSON-RPC.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ flowchart LR
|
||||
cfg --> plugin_acp_tool_subagent_fork
|
||||
plugin_acp_tool_todo["tool-todo<br/>@deepseek-ai/dsh-tool-todo"]
|
||||
cfg --> plugin_acp_tool_todo
|
||||
plugin_acp_repeat_tool_guard["repeat-tool-guard<br/>@deepseek-ai/dsh-repeat-tool-guard"]
|
||||
cfg --> plugin_acp_repeat_tool_guard
|
||||
plugin_acp_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"]
|
||||
cfg --> plugin_acp_fs_local
|
||||
plugin_acp_fs_policy["fs-policy<br/>@deepseek-ai/dsh-fs-policy"]
|
||||
@@ -56,6 +58,7 @@ flowchart LR
|
||||
| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` |
|
||||
| `tool-todo` | `@deepseek-ai/dsh-tool-todo` |
|
||||
| `repeat-tool-guard` | `@deepseek-ai/dsh-repeat-tool-guard` |
|
||||
| `fs-local` | `@deepseek-ai/dsh-fs-local` |
|
||||
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
|
||||
| `tool-fs` | `@deepseek-ai/dsh-tool-fs` |
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
# The repeat-tool-call guard: advisory reminders (injected context, never a
|
||||
# block) when the model re-issues the same tool call with identical arguments;
|
||||
# defaults [3, 5, 8]. Loaded here so the snapshot tier exercises the reminder
|
||||
# transcript (the repeat-tool-guard scenario) — no other scenario repeats a
|
||||
# call three times, so it is inert everywhere else.
|
||||
- id: repeat-tool-guard
|
||||
name: '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
|
||||
# Filesystem capability stack: local provider, read-before-write/edit policy
|
||||
# gate, then the model-facing read/write/edit tools. Relative filesystem paths
|
||||
# resolve from the server launch cwd; the documented Zed setup launches this
|
||||
|
||||
@@ -57,8 +57,8 @@ interface Spawned {
|
||||
}
|
||||
|
||||
// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with
|
||||
// hooks.e2e.ts and partly with snapshot-harness.ts. Extract one shared ACP test
|
||||
// launcher before the TSX/env/permission-stub details drift again.
|
||||
// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e
|
||||
// files onto that launcher before the TSX/env/permission-stub details drift.
|
||||
function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
@@ -101,6 +101,46 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn
|
||||
let spawned: Spawned | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
function hasStdoutLine(out: string[]): boolean {
|
||||
return out.join('').split('\n').some(line => line.trim().length > 0)
|
||||
}
|
||||
|
||||
async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const pass = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
cleanup()
|
||||
reject(new Error(`${reason}; stderr: ${stderr.join('')}`))
|
||||
}
|
||||
const onData = () => {
|
||||
if (hasStdoutLine(out)) pass()
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
fail(`ACP child failed before emitting a stdout frame: ${error.message}`)
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
onData()
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned) {
|
||||
spawned.child.kill('SIGKILL')
|
||||
@@ -129,16 +169,21 @@ describe('acp-agent over real stdio (no key required)', () => {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const out: string[] = []
|
||||
const stderr: string[] = []
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => out.push(c))
|
||||
child.stderr.on('data', (c: string) => stderr.push(c))
|
||||
|
||||
// Send a single initialize request as a newline-delimited JSON-RPC frame.
|
||||
const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
|
||||
child.stdin.write(req + '\n')
|
||||
|
||||
// Give it a moment to boot + reply, then inspect stdout.
|
||||
await new Promise(r => setTimeout(r, 4000))
|
||||
child.kill('SIGKILL')
|
||||
try {
|
||||
await waitForStdoutLine(child, out, stderr, 15_000)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -1,70 +1,37 @@
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type HarvestedLog, type InputScript, runScenario } from './snapshot-harness.ts'
|
||||
import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize.ts'
|
||||
import { defineAcpSnapshotSuite, type Scenario } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
|
||||
/**
|
||||
* ACP snapshot tests (REPLAY by default, keyless). Each scenario under
|
||||
* `snapshots/<name>/` ships an `input.json` (the client stdin script) and a
|
||||
* `session.jsonl` fixture; replay boots the real acp-agent subprocess, drives
|
||||
* it, and diffs the normalized stdout transcript against the committed
|
||||
* `stdout.golden.jsonl`. For model scenarios it ALSO checks the re-persisted
|
||||
* session log — against the `session.jsonl` fixture itself, not a separate
|
||||
* golden: the fixture doubles as the replay source (recorded scenarios) and the
|
||||
* expected produced log (both sides normalized before comparing).
|
||||
*
|
||||
* `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the
|
||||
* `session.jsonl` fixtures against the real API and refreshes the stdout golden
|
||||
* in one pass.
|
||||
* The acp-agent example's snapshot suite: the scenario table for
|
||||
* `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
|
||||
* (golden + re-persisted-log diffs, record write-back, the pinned-header
|
||||
* uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
|
||||
* `pnpm run test:snapshot:record` re-records the `recorded` scenarios against
|
||||
* the real API. See the package README (packages/support/acp-snapshot) and the
|
||||
* snapshot RFC, docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
|
||||
*/
|
||||
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const RECORDING = process.env.DSH_SNAPSHOT === 'record'
|
||||
|
||||
/** A snapshot scenario and how its fixtures are produced. */
|
||||
interface Scenario {
|
||||
name: string
|
||||
/** Whether the scenario drives at least one model turn (so a JSONL golden applies). */
|
||||
hasModelTurn: boolean
|
||||
/**
|
||||
* Whether the run persists a comparable session log to diff against the
|
||||
* `session.jsonl` fixture. Defaults to {@link hasModelTurn} (a model turn
|
||||
* always produces a log worth comparing). Set it independently for a scenario
|
||||
* that produces a non-trivial log WITHOUT a model turn — e.g. a prompt blocked
|
||||
* by a `UserPromptSubmit` hook, which opens a `rejected` turn carrying `hook/*`
|
||||
* events but never calls the model.
|
||||
*/
|
||||
comparesLog?: boolean
|
||||
/**
|
||||
* Whether `test:snapshot:record` regenerates this scenario's `session.jsonl`
|
||||
* from the LIVE API. `recorded` scenarios are model-driven and reproducible;
|
||||
* `authored` scenarios (a hand-written `replay.override.json` sidecar drives
|
||||
* replay — e.g. a provider error or a cancel, which the live API can't be
|
||||
* coaxed into deterministically — or a deterministic hook scenario whose
|
||||
* derived empty script needs no sidecar) are NEVER re-recorded.
|
||||
*/
|
||||
recorded: boolean
|
||||
/**
|
||||
* How many SUBAGENT child sessions this scenario records beyond the top-level
|
||||
* one (0 for a single-session scenario). Each child rides in a sibling fixture
|
||||
* `session.<n>.jsonl` (1-based); replay forwards them to `dsh-llm-replay` so
|
||||
* each child session replays from its own script, and record mode writes the
|
||||
* harvested child logs back to those files. Defaults to 0.
|
||||
*/
|
||||
childSessions?: number
|
||||
// The dsh-acp-agent bin (the demo:acp entry), this example's cordis.yml, and
|
||||
// the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
|
||||
// ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
|
||||
const AGENT = {
|
||||
binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
}
|
||||
|
||||
const SCENARIOS: Scenario[] = [
|
||||
{ name: 'handshake', hasModelTurn: false, recorded: false },
|
||||
{ name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
|
||||
{ name: 'text-turn', hasModelTurn: true, recorded: true },
|
||||
// text-turn is the pinned-header scenario: the minimal single text turn,
|
||||
// whose fixture is the ONE place the full system prompt + tool schemas are
|
||||
// committed and compared verbatim.
|
||||
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
|
||||
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
|
||||
{ name: 'todo-plan', hasModelTurn: true, recorded: true },
|
||||
{ name: 'skill-load', hasModelTurn: true, recorded: false },
|
||||
{ name: 'skill-load', hasModelTurn: true, recorded: false, overridden: true, variesHeader: true },
|
||||
{ name: 'workspace-edit', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-read', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-write', hasModelTurn: true, recorded: true },
|
||||
@@ -73,8 +40,13 @@ const SCENARIOS: Scenario[] = [
|
||||
{ name: 'fs-read-window', hasModelTurn: true, recorded: true },
|
||||
{ name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
|
||||
{ name: 'multi-turn', hasModelTurn: true, recorded: true },
|
||||
{ name: 'error-finish', hasModelTurn: true, recorded: false },
|
||||
{ name: 'cancel', hasModelTurn: true, recorded: false },
|
||||
{ name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
|
||||
// Keyless, authored (like error-finish/cancel): deterministically forcing a
|
||||
// LIVE model to repeat one call three times is not a stable recording, so
|
||||
// the fixture scripts five identical todo_write calls and pins BOTH reminder
|
||||
// tiers (gentle at 3, detailed at 5) as context/message in transcript and log.
|
||||
{ name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
|
||||
{ name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
|
||||
{ name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
{ name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 },
|
||||
{ name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 },
|
||||
@@ -120,138 +92,9 @@ const SCENARIOS: Scenario[] = [
|
||||
{ name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
|
||||
]
|
||||
|
||||
/** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */
|
||||
function childFixturePaths(dir: string, childSessions: number): string[] {
|
||||
return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own
|
||||
* header line (`{ type: 'session', id, cwd }`). A committed fixture carries the
|
||||
* session id and cwd of the run that harvested it — different from the live
|
||||
* replay run — so normalizing it against the live run's ctx would leave those
|
||||
* recorded values unscrubbed. Reading them from the header scrubs the fixture's
|
||||
* own id/cwd to the same `{{sessionId}}`/`{{cwd}}` tokens the replay output gets.
|
||||
* An authored fixture whose header is already normalized (`id:'{{sessionId}}'`,
|
||||
* `cwd:'{{cwd}}'`) yields those tokens as the volatile values, so scrubbing them
|
||||
* is an idempotent no-op. A header with no `cwd` falls back to a sentinel that
|
||||
* cannot occur in a log (NOT `''`, which `String.split` would match on every
|
||||
* character boundary and corrupt the output).
|
||||
*/
|
||||
function fixtureContext(fixture: string): NormalizeContext {
|
||||
const firstLine = fixture.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
const header = JSON.parse(firstLine) as { id?: unknown; cwd?: unknown }
|
||||
return {
|
||||
sessionIds: typeof header.id === 'string' ? [header.id] : [],
|
||||
cwd: typeof header.cwd === 'string' ? header.cwd : '\0no-cwd\0',
|
||||
}
|
||||
}
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
describe(`snapshot: ${scenario.name}`, () => {
|
||||
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the
|
||||
// `authored` ones (sidecar-driven errors/cancel) are never re-recorded.
|
||||
it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => {
|
||||
const dir = join(SNAPSHOTS_DIR, scenario.name)
|
||||
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
|
||||
const overrideFile = join(dir, 'replay.override.json')
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const childSessions = scenario.childSessions ?? 0
|
||||
const result = await runScenario(input, {
|
||||
mode: RECORDING ? 'record' : 'replay',
|
||||
fixtureFile: join(dir, 'session.jsonl'),
|
||||
...existsSync(overrideFile) ? { overrideFile } : {},
|
||||
// In REPLAY, forward the recorded child fixtures so each subagent session
|
||||
// replays from its own script. In RECORD they are harvested, not read.
|
||||
...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {},
|
||||
...existsSync(workspaceDir) ? { workspaceDir } : {},
|
||||
})
|
||||
|
||||
// Scrub every volatile id the run produced: the ACP server-issued session
|
||||
// id plus every harvested log's recorded id (a subagent child id never
|
||||
// surfaces over ACP, but it appears in the child's own log header). The
|
||||
// normalizer's UUID catch-all covers any we don't enumerate.
|
||||
const ctx: NormalizeContext = {
|
||||
sessionIds: [
|
||||
...result.sessionId !== undefined ? [result.sessionId] : [],
|
||||
...result.sessionLogs.map(l => l.id),
|
||||
],
|
||||
cwd: result.cwd,
|
||||
}
|
||||
|
||||
// RECORD mode (recorded model scenarios only): persist the freshly-harvested
|
||||
// logs back to their fixtures — the primary to session.jsonl, each child to
|
||||
// session.<n>.jsonl in harvest order. `--update` refreshes the Vitest
|
||||
// goldens but NOT these fixtures, so write them here.
|
||||
if (RECORDING && scenario.recorded && scenario.hasModelTurn) {
|
||||
expect(result.sessionLogs.length, 'record produced no session log to harvest').toBeGreaterThan(0)
|
||||
expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`)
|
||||
.toBe(childSessions + 1)
|
||||
await writeFile(join(dir, 'session.jsonl'), (result.sessionLogs[0] as HarvestedLog).content)
|
||||
for (let i = 1; i < result.sessionLogs.length; i++) {
|
||||
await writeFile(join(dir, `session.${i}.jsonl`), (result.sessionLogs[i] as HarvestedLog).content)
|
||||
}
|
||||
}
|
||||
|
||||
await expect(normalizeStdout(result.rawStdout, ctx))
|
||||
.toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl'))
|
||||
|
||||
// A model turn always produces a log worth comparing; a hook scenario can
|
||||
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
|
||||
const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn
|
||||
if (comparesLog) {
|
||||
// The harvested logs (primary-first) must match their committed fixtures
|
||||
// 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS
|
||||
// OWN volatile values — the live run's via `ctx`, the committed fixture's
|
||||
// via its own header (a committed file cannot share the live run's ids).
|
||||
expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1)
|
||||
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
|
||||
for (let i = 0; i < fixtureFiles.length; i++) {
|
||||
const harvested = (result.sessionLogs[i] as HarvestedLog).content
|
||||
const fixture = await readFile(join(dir, fixtureFiles[i] as string), 'utf8')
|
||||
expect(normalizeSessionLog(harvested, ctx), `${fixtureFiles[i]} mismatch`)
|
||||
.toEqual(normalizeSessionLog(fixture, fixtureContext(fixture)))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('snapshot fixtures', () => {
|
||||
it('every scenario directory is registered (no orphans)', async () => {
|
||||
// toMatchFileSnapshot does not prune orphaned golden/fixture files, so a
|
||||
// renamed/removed scenario could leave a stale dir that nothing exercises.
|
||||
// Fail loud on any snapshots/<dir> not present in SCENARIOS.
|
||||
const entries = await readdir(SNAPSHOTS_DIR, { withFileTypes: true })
|
||||
const onDisk = entries.filter(e => e.isDirectory()).map(e => e.name).sort()
|
||||
const registered = SCENARIOS.map(s => s.name).sort()
|
||||
expect(onDisk).toEqual(registered)
|
||||
})
|
||||
|
||||
it('every registered scenario has its required fixture files', async () => {
|
||||
// Every scenario has an input script and an stdout golden. EVERY scenario
|
||||
// also needs `session.jsonl`: the harness boots `llm-replay` with that path
|
||||
// as the replay source for ALL scenarios (acp.snapshot.ts passes
|
||||
// `fixtureFile: <dir>/session.jsonl` unconditionally), and `loadReplayScript`
|
||||
// throws "fixture not found" when it is absent and no override replaces it.
|
||||
// A no-model scenario ships a header-only `session.jsonl` (it derives to an
|
||||
// empty script — no model call is made); a model scenario's fixture also
|
||||
// doubles as the expected-log artifact the run is diffed against. An authored
|
||||
// (non-`recorded`) model scenario additionally ships a `replay.override.json`
|
||||
// sidecar for the throw/hang cases a derived script cannot express.
|
||||
for (const { name, hasModelTurn, recorded, childSessions } of SCENARIOS) {
|
||||
const dir = join(SNAPSHOTS_DIR, name)
|
||||
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
|
||||
expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true)
|
||||
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
|
||||
if (hasModelTurn && !recorded) {
|
||||
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true)
|
||||
}
|
||||
// A nested-agent scenario ships one child fixture per recorded subagent
|
||||
// session (`session.1.jsonl` …), the replay source for that child session.
|
||||
for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) {
|
||||
expect(existsSync(childFixture), childFixture).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
defineAcpSnapshotSuite({
|
||||
agent: AGENT,
|
||||
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
|
||||
scenarios: SCENARIOS,
|
||||
mode: process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay',
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from '../tests/snapshot-normalize.ts'
|
||||
|
||||
/**
|
||||
* Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
|
||||
* the default unit gate) and import the harness-side normalizers directly.
|
||||
*/
|
||||
|
||||
const ctx: NormalizeContext = {
|
||||
sessionIds: ['11111111-2222-3333-4444-555555555555'],
|
||||
cwd: '/tmp/acp-snap-cwd-abc123',
|
||||
}
|
||||
|
||||
describe('normalizeStdout', () => {
|
||||
it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
|
||||
const raw = [
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
|
||||
].join('\n')
|
||||
const out = normalizeStdout(raw, ctx)
|
||||
expect(out).toContain('"id":1')
|
||||
expect(out).toContain('"id":2')
|
||||
expect(out).not.toContain('42')
|
||||
expect(out).not.toContain('99')
|
||||
})
|
||||
|
||||
it('scrubs the cwd and session id anywhere they appear', () => {
|
||||
const raw = JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'session/update',
|
||||
params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
|
||||
})
|
||||
const out = normalizeStdout(raw, ctx)
|
||||
expect(out).toContain('{{sessionId}}')
|
||||
expect(out).toContain('{{cwd}}')
|
||||
expect(out).not.toContain(ctx.cwd)
|
||||
expect(out).not.toContain(ctx.sessionIds[0] as string)
|
||||
})
|
||||
|
||||
it('scrubs a stray UUID not in the known list', () => {
|
||||
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
|
||||
expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
|
||||
})
|
||||
|
||||
it('leaves notification frames without an id untouched in id-space', () => {
|
||||
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
|
||||
const out = normalizeStdout(raw, ctx)
|
||||
expect(out).not.toContain('"id"')
|
||||
})
|
||||
|
||||
it('throws on a non-JSON stdout line (the purity check)', () => {
|
||||
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
|
||||
expect(() => normalizeStdout(raw, ctx)).toThrow()
|
||||
})
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
|
||||
expect(() => normalizeStdout(raw, ctx)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeSessionLog', () => {
|
||||
const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
|
||||
const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
|
||||
|
||||
it('zeroes the header createdAt', () => {
|
||||
const out = normalizeSessionLog(`${header({})}\n`, ctx)
|
||||
expect(out).toContain('"createdAt":0')
|
||||
expect(out).not.toContain('123')
|
||||
})
|
||||
|
||||
it('zeroes each event time but keeps seq', () => {
|
||||
const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
|
||||
expect(out).toContain('"time":0')
|
||||
expect(out).toContain('"seq":7') // seq is deterministic — NOT scrubbed
|
||||
expect(out).not.toContain('999')
|
||||
})
|
||||
|
||||
it('scrubs cwd and session id deep inside event data', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('{{cwd}}')
|
||||
expect(out).not.toContain(ctx.cwd)
|
||||
})
|
||||
|
||||
it('scrubs the session id in the header', () => {
|
||||
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
|
||||
expect(out).toContain('{{sessionId}}')
|
||||
})
|
||||
|
||||
it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'hook/result', seq: 2, time: 5,
|
||||
data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('"durationMs":0')
|
||||
expect(out).not.toContain('37')
|
||||
expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
|
||||
})
|
||||
|
||||
it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
|
||||
const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
|
||||
const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
|
||||
expect(out).toContain('"durationMs":88')
|
||||
})
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -137,7 +137,7 @@
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" perl"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" awk"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" replace"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}}
|
||||
@@ -152,7 +152,7 @@
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" file"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"perl -0pi -e 's/blue/green/g' settings.txt","kind":"execute","status":"in_progress","rawInput":"perl -0pi -e 's/blue/green/g' settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","title":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","kind":"execute","status":"in_progress","rawInput":"awk '{gsub(/blue/,\"green\")}1' settings.txt > settings.tmp && mv settings.tmp settings.txt","content":[{"type":"content","content":{"type":"text","text":"Replace blue with green in settings.txt"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_SvvpTh6bWybYXoO77NHg8535","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\n(no output)\n```"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Write the todo list 'watch the kettle boil' five times in a row without changing it, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_1","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"call_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":11,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_2","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":22,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"call_2","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_3","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":33,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"call_3","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":35,"time":0,"data":{"content":[{"type":"text","text":"You are repeating the exact same tool call with identical arguments. Carefully analyze the previous result before calling again: if the task is not complete, try a different approach or different arguments instead of repeating the call."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_4","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"call_4","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":45,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":46,"time":0,"data":{"turn":1,"step":4,"callId":"call_4","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[44],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":47,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":48,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_5","name":"todo_write","argumentsDelta":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":54,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}],"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"watch the kettle boil\", \"status\": \"in_progress\"}]}"}}
|
||||
{"type":"todo/write","seq":56,"time":0,"data":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}
|
||||
{"type":"tool/result","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"call_5","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[55],"surfaceOp":"append"}
|
||||
{"type":"context/message","seq":58,"time":0,"data":{"content":[{"type":"text","text":"Repeated tool call detected:\n- tool: todo_write\n- consecutive_calls: 5\n- arguments: {\"todos\":[{\"content\":\"watch the kettle boil\",\"status\":\"in_progress\"}]}\nThe repeated calls are not making progress. Do not call this tool with these exact arguments again. Inspect the latest result and choose a different action, different arguments, or finish the task if enough evidence has been gathered."}],"source":{"kind":"plugin","plugin":"repeat-tool-guard"}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"DONE."}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE."}}}}
|
||||
{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":66,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"DONE."}],"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[61,62,63,64,65],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":67,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":68,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_2","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_3","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_3","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_4","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_4","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_5","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE."}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user