From 148046b9c8688e833d0a7904f970bdf8719e88c2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:27:00 +0800 Subject: [PATCH] docs: rebalance prose cleanup and add trimming skill --- .agents/skills/dsh-code-review/SKILL.md | 3 +- .agents/skills/dsh-doc-standards/SKILL.md | 8 +- .agents/skills/dsh-trim-prose/SKILL.md | 71 ++++++++++ .../skills/dsh-trim-prose/agents/openai.yaml | 4 + .../dsh-trim-prose/references/examples.md | 127 ++++++++++++++++++ AGENTS.md | 13 +- docs/AGENTS.md | 8 +- docs/architecture.md | 2 +- docs/config-catalog.md | 80 ++++++----- docs/cookbook/adding-a-tool.md | 2 +- docs/cordis-catalog/events.md | 58 ++++---- docs/cordis-catalog/services.md | 34 ++--- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/compaction.md | 2 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/filesystem.md | 2 +- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/tools.md | 4 +- docs/core-data-structures/web.md | 2 +- docs/event-producer-consumer.md | 38 +++--- docs/persistence-catalog.md | 48 +++---- docs/rfc/INDEX.md | 9 +- docs/rfc/implemented/AGENTS.md | 2 +- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-06-11-custom-schema-dsl.md | 4 +- .../2026-06-11-event-sourced-sessions.md | 2 +- .../2026-06-11-structured-error-taxonomy.md | 4 +- .../2026-06-13-twin-llm-adapters.md | 2 +- .../2026-06-14-session-persistence.md | 6 +- ...-18-agent-lifecycle-and-ownership-seams.md | 7 + .../architecture/2026-06-20-branded-ids.md | 4 + ...2026-06-20-extract-example-app-packages.md | 7 + .../2026-06-26-file-context-as-event-gate.md | 4 + .../2026-06-30-event-domain-semantics.md | 2 +- ...07-03-filesystem-directory-listing-seam.md | 4 +- ...t-variables-and-tool-guidance-ownership.md | 11 +- .../2026-07-05-reconstructable-requests.md | 6 +- ...7-05-subagent-provider-lifecycle-events.md | 6 +- .../2026-07-12-agent-scope-runtime-design.md | 4 +- .../2026-06-14-acp-agent-client-protocol.md | 2 +- .../feature/2026-06-15-code-mode.md | 13 +- .../2026-06-18-compaction-capability-seam.md | 7 + .../2026-06-21-subagent-capability-seam.md | 4 + .../2026-06-22-acp-subagent-backend.md | 6 + .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-protocol-lib.md | 2 +- .../feature/2026-07-05-dynamic-workflows.md | 10 +- .../feature/2026-07-06-approval-seam.md | 9 +- .../feature/2026-07-06-explicit-tool-order.md | 4 + .../implemented/feature/2026-07-06-sandbox.md | 15 ++- .../feature/2026-07-08-repeat-tool-guard.md | 6 + ...6-07-08-self-referential-cordis-toolset.md | 2 +- ...2026-06-20-core-data-structures-catalog.md | 8 +- ...-20-remove-agent-boundary-mirror-events.md | 9 +- .../2026-07-04-drop-image-content-block.md | 4 + .../2026-07-04-fold-stdio-ui-helper.md | 2 +- ...26-07-04-tighten-hook-protocol-contract.md | 4 + ...-04-trim-acp-bridge-unreachable-surface.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../testing/2026-06-19-real-api-e2e-ci.md | 12 +- .../2026-06-22-subagent-snapshot-replay.md | 2 +- .../2026-07-04-hook-snapshot-matrix.md | 2 +- .../2026-07-08-shared-acp-snapshot-package.md | 4 + .../2026-06-16-typed-event-schemas.md | 14 +- ...claude-code-and-codex-subagent-backends.md | 14 +- .../2026-07-08-interactive-side-sessions.md | 39 ++++++ ...am-workflow-progress-through-tool-calls.md | 41 ++++++ .../2026-06-20-unify-agent-and-session-id.md | 54 +++----- ...026-07-04-prune-dead-core-spine-surface.md | 54 ++++++-- ...2-drop-unconsumed-skill-provider-events.md | 32 +++++ ...2026-07-12-prune-unused-web-seam-fields.md | 30 +++++ ...-12-simplify-session-log-representation.md | 36 +++++ ...12-collapse-workflow-to-foreground-core.md | 37 +++++ ...-12-prune-unused-skill-registry-surface.md | 27 ++++ docs/testing.md | 2 +- docs/tool-execution-pipeline.md | 2 +- eslint.config.mjs | 5 +- examples/AGENTS.md | 18 +-- examples/README.md | 4 +- .../acp-agent/both-mode.cordis.snapshot.yml | 9 +- examples/acp-agent/both-mode.cordis.yml | 12 +- .../acp-agent/code-mode.cordis.snapshot.yml | 9 +- examples/acp-agent/code-mode.cordis.yml | 14 +- examples/acp-agent/cordis.snapshot.yml | 31 ++--- examples/acp-agent/cordis.yml | 86 ++++-------- examples/acp-agent/tests/acp.e2e.ts | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 9 +- examples/acp-agent/tests/hooks.e2e.ts | 9 +- examples/coding-agent/code-mode.cordis.yml | 15 +-- examples/coding-agent/cordis.yml | 59 +++----- .../tests/code-mode-keyless-smoke.e2e.ts | 6 +- examples/coding-agent/tests/code-mode.e2e.ts | 5 +- examples/coding-agent/tests/compaction.e2e.ts | 8 +- .../coding-agent/tests/keyless-smoke.e2e.ts | 8 +- examples/cordis-agent/README.md | 2 +- examples/cordis-agent/cordis.yml | 25 ++-- .../cordis-agent/tests/cordis-tools.e2e.ts | 3 +- .../cordis-agent/tests/keyless-smoke.e2e.ts | 8 +- examples/echo-agent/cordis.yml | 14 +- examples/echo-agent/tests/echo.e2e.ts | 12 +- examples/sandbox-acp-agent/README.md | 4 +- .../sandbox-acp-agent/cordis.snapshot.yml | 26 +--- examples/sandbox-acp-agent/cordis.yml | 41 ++---- .../sandbox-acp-agent/tests/acp.snapshot.ts | 15 ++- .../sandbox-acp-agent/tests/escalation.e2e.ts | 9 +- packages/bash/bash-local/src/index.ts | 6 +- packages/bash/bash-local/src/run.ts | 19 +-- .../bash/bash-local/tests/executor.spec.ts | 2 +- packages/bash/bash-local/tests/run.spec.ts | 10 +- packages/bash/bash-sandbox/README.md | 2 +- packages/bash/bash-sandbox/src/index.ts | 38 +++--- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 10 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 9 +- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 7 +- packages/bash/bash/src/index.ts | 14 +- packages/bash/bash/src/session-mode.ts | 5 +- packages/bash/bash/src/types.ts | 5 +- packages/bash/tool-bash/README.md | 2 +- packages/bash/tool-bash/src/index.ts | 29 ++-- packages/bash/tool-bash/tests/tools.spec.ts | 46 ++++--- .../code-runtime-worker/src/bootstrap.ts | 11 +- .../code-runtime-worker/src/index.ts | 22 ++- .../code-runtime-worker/src/protocol.ts | 4 +- .../code-runtime-worker/src/worker.ts | 4 +- .../tests/built-lib.e2e.ts | 7 +- .../code-runtime-worker/tests/runtime.spec.ts | 4 +- .../code-runtime-worker/tsdown.config.ts | 7 +- .../code-runtime/code-runtime/src/index.ts | 10 +- packages/compact/compact-basic/README.md | 10 +- packages/compact/compact-basic/src/index.ts | 35 ++--- .../compact-basic/tests/compact-basic.spec.ts | 50 +++---- .../tests/compact-loop-repro.spec.ts | 12 +- packages/compact/compact/src/index.ts | 2 +- packages/compact/compact/src/render.ts | 13 +- packages/compact/compact/src/types.ts | 4 + packages/cordis/tool-cordis/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 22 +-- .../cordis/tool-cordis/src/fiber-state.ts | 6 +- packages/cordis/tool-cordis/src/guard.ts | 16 ++- packages/cordis/tool-cordis/src/index.ts | 7 +- packages/cordis/tool-cordis/src/inspect.ts | 4 +- packages/cordis/tool-cordis/src/mount.ts | 4 +- packages/cordis/tool-cordis/src/sandbox.ts | 12 +- .../cordis/tool-cordis/tests/mount.spec.ts | 11 +- .../tool-cordis/tests/sandbox-context.spec.ts | 19 ++- packages/core/agent-core/README.md | 2 +- packages/core/agent-core/src/index.ts | 10 +- .../core/agent-core/tests/agent-core.spec.ts | 3 +- packages/core/agent-loop/README.md | 8 +- packages/core/agent-loop/src/agent.ts | 17 ++- packages/core/agent-loop/src/index.ts | 6 +- packages/core/agent-loop/src/loop.ts | 32 +++-- packages/core/agent-loop/src/request-log.ts | 12 +- packages/core/agent-loop/tests/agent.spec.ts | 20 ++- packages/core/agent-loop/tests/cancel.spec.ts | 19 +-- .../tests/config-session-id.spec.ts | 3 +- ...s.spec.ts => contract-regressions.spec.ts} | 36 +++-- .../agent-loop/tests/coverage-edges.spec.ts | 4 +- packages/core/agent-loop/tests/inbox.spec.ts | 18 +-- .../agent-loop/tests/interception.spec.ts | 13 +- packages/core/agent-loop/tests/loop.spec.ts | 15 +-- .../core/agent-loop/tests/properties.spec.ts | 8 +- .../agent-loop/tests/request-cache.e2e.ts | 3 +- .../tests/request-reconstruction.spec.ts | 3 +- packages/core/agent-loop/tests/resume.spec.ts | 6 +- .../core/agent-loop/tests/tool-order.spec.ts | 3 +- packages/core/agent/src/dispatch.ts | 10 +- packages/core/agent/src/index.ts | 13 +- packages/core/agent/src/types.ts | 29 ++-- .../agent/tests/verify-export-jsdoc.spec.ts | 10 +- packages/core/scope/src/index.ts | 6 +- packages/core/session/README.md | 16 +-- packages/core/session/src/index.ts | 20 ++- packages/core/session/src/json.ts | 7 +- packages/core/session/src/repair.ts | 22 +-- packages/core/session/src/request-header.ts | 12 +- packages/core/session/src/surface.ts | 5 +- packages/core/session/src/tool-pairing.ts | 11 +- packages/core/session/src/types.ts | 40 +++--- .../core/session/tests/derived-cache.spec.ts | 6 +- .../core/session/tests/properties.spec.ts | 4 +- packages/core/session/tests/repair.spec.ts | 5 +- packages/core/session/tests/session.spec.ts | 8 +- packages/core/session/tests/surface.spec.ts | 5 +- .../core/session/tests/tool-pairing.spec.ts | 11 +- packages/core/system-prompt/src/index.ts | 24 ++-- packages/core/tools/README.md | 19 ++- packages/core/tools/src/code-mode.ts | 6 +- packages/core/tools/src/index.ts | 29 +++- packages/core/tools/src/json-schema.ts | 8 +- packages/core/tools/src/presentation.ts | 6 +- packages/core/tools/src/schema.ts | 3 +- packages/core/tools/src/ts-types.ts | 4 +- packages/core/tools/tests/code-mode.spec.ts | 4 +- packages/core/tools/tests/tools.spec.ts | 8 +- packages/fs/fs-local/README.md | 2 +- packages/fs/fs-local/src/fsio.ts | 20 +-- packages/fs/fs-local/src/index.ts | 6 +- packages/fs/fs-policy/README.md | 2 +- packages/fs/fs-policy/src/index.ts | 10 +- packages/fs/fs-policy/tests/policy.spec.ts | 4 +- packages/fs/fs/src/index.ts | 35 ++--- packages/fs/fs/src/types.ts | 9 +- packages/fs/tool-fs/src/diff.ts | 9 +- packages/fs/tool-fs/src/edit.ts | 10 +- packages/fs/tool-fs/src/index.ts | 5 +- packages/fs/tool-fs/src/read-render.ts | 10 +- packages/fs/tool-fs/src/read.ts | 8 +- packages/fs/tool-fs/src/session-cwd.ts | 2 + packages/fs/tool-fs/src/write.ts | 10 +- packages/fs/tool-fs/tests/integration.spec.ts | 9 +- packages/fs/tool-fs/tests/tools.spec.ts | 3 +- packages/guard/repeat-tool-guard/src/index.ts | 7 +- packages/hooks/hook-protocol/README.md | 2 +- packages/hooks/hook-protocol/src/codec.ts | 16 ++- packages/hooks/hook-protocol/src/detached.ts | 4 +- packages/hooks/hook-protocol/src/events.ts | 13 +- packages/hooks/hook-protocol/src/index.ts | 8 +- packages/hooks/hook-protocol/src/matcher.ts | 11 +- packages/hooks/hook-protocol/src/merge.ts | 10 +- packages/hooks/hook-protocol/src/runner.ts | 15 ++- packages/hooks/hook-protocol/src/types.ts | 12 +- packages/hooks/hooks-claude/src/config.ts | 10 +- packages/hooks/hooks-claude/src/index.ts | 20 +-- .../hooks/hooks-claude/tests/bridge.spec.ts | 22 +-- .../hooks/hooks-claude/tests/coverage.spec.ts | 25 ++-- packages/hooks/hooks-codex/src/config.ts | 8 +- packages/hooks/hooks-codex/src/index.ts | 13 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 13 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 9 +- packages/llm/llm-deepseek/src/adapter.ts | 5 +- packages/llm/llm-deepseek/src/index.ts | 5 +- packages/llm/llm-deepseek/src/serialize.ts | 6 +- packages/llm/llm-deepseek/src/sse.ts | 4 + packages/llm/llm-deepseek/src/translate.ts | 5 + .../llm/llm-deepseek/tests/serialize.spec.ts | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 12 +- packages/llm/llm-pi-ai/src/convert.ts | 10 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/assembler.ts | 4 +- packages/llm/llm/src/attribution.ts | 4 + packages/llm/llm/src/call-config.ts | 11 +- packages/llm/llm/src/error.ts | 4 +- packages/llm/llm/src/index.ts | 5 +- packages/llm/llm/src/never.ts | 9 +- packages/llm/llm/src/types.ts | 12 +- packages/llm/llm/tests/assembler.spec.ts | 9 +- packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox-local/src/index.ts | 40 +++--- .../sandbox/sandbox-local/tests/bwrap.e2e.ts | 7 +- .../sandbox-local/tests/landlock.e2e.ts | 8 +- .../sandbox/sandbox-local/tests/local.spec.ts | 3 +- .../sandbox-local/tests/packed-install.e2e.ts | 14 +- .../sandbox-local/tests/seatbelt.e2e.ts | 8 +- packages/sandbox/sandbox/src/index.ts | 29 ++-- .../session-persistence-jsonl/src/format.ts | 23 ++-- .../session-persistence-jsonl/src/index.ts | 19 +-- .../tests/jsonl.spec.ts | 24 ++-- .../session-persistence-sqlite/src/index.ts | 12 +- .../session-persistence-sqlite/src/schema.ts | 14 +- .../tests/sqlite.spec.ts | 16 ++- .../session-persistence/README.md | 4 +- .../session-persistence/src/coordinator.ts | 9 +- .../session-persistence/src/index.ts | 11 +- .../session-persistence/tests/contract.ts | 8 +- .../tests/coordinator-contract.ts | 29 ++-- .../tests/persistence.spec.ts | 12 +- packages/skill/skill-local/README.md | 2 +- packages/skill/skill/src/index.ts | 17 +-- packages/skill/tool-skill/README.md | 6 +- packages/skill/tool-skill/src/index.ts | 4 +- packages/subagent/subagent-acp/src/index.ts | 7 +- packages/subagent/subagent-acp/src/run.ts | 12 +- .../subagent-acp/tests/mock-acp-server.ts | 21 +-- .../subagent-acp/tests/subagent-acp.e2e.ts | 4 +- packages/subagent/subagent-fork/src/index.ts | 8 +- .../subagent-fork/tests/subagent-fork.spec.ts | 6 +- .../subagent-inprocess/src/structured.ts | 10 +- .../tests/structured.spec.ts | 13 +- packages/subagent/subagent-spawn/src/index.ts | 4 +- .../tests/subagent-spawn.spec.ts | 6 +- .../subagent/subagent-subprocess/src/index.ts | 19 +-- .../tests/subagent-subprocess.spec.ts | 4 +- packages/subagent/subagent/src/types.ts | 20 +-- packages/subagent/tool-subagent/src/index.ts | 14 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 30 +++-- packages/support/acp-snapshot/src/index.ts | 5 +- .../support/acp-snapshot/src/normalize.ts | 9 +- packages/support/acp-snapshot/src/suite.ts | 21 ++- .../tests/fixtures/fake-acp-agent.ts | 4 +- .../acp-snapshot/tests/harness.spec.ts | 3 +- .../support/acp-snapshot/tests/suite.spec.ts | 3 + packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 12 +- .../invariants/tests/invariants.spec.ts | 19 +-- packages/support/llm-replay/src/index.ts | 36 ++--- .../llm-replay/tests/llm-replay.spec.ts | 7 +- packages/support/subagent-mock/src/index.ts | 5 +- .../subagent-mock/tests/subagent-mock.spec.ts | 2 +- packages/timeout/timeout-policy/src/index.ts | 8 +- packages/todo/tool-todo/src/index.ts | 8 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 2 +- packages/ui/acp-agent/src/bin.ts | 8 +- packages/ui/acp-agent/src/index.ts | 4 + packages/ui/acp-agent/tests/acp-agent.spec.ts | 3 +- packages/ui/acp-agent/tests/built-bin.e2e.ts | 13 +- packages/ui/acp-agent/tests/load-path.e2e.ts | 12 +- packages/ui/acp/src/codec.ts | 7 +- packages/ui/acp/src/index.ts | 8 +- packages/ui/acp/tests/dispose.spec.ts | 39 ++++-- packages/ui/acp/tests/harness.ts | 16 ++- packages/ui/acp/tests/load.spec.ts | 15 ++- packages/ui/acp/tests/properties.spec.ts | 4 +- packages/ui/acp/tests/stream-update.spec.ts | 17 ++- packages/ui/acp/tests/turns.spec.ts | 22 ++- packages/ui/app-boot/src/index.ts | 22 +-- packages/ui/stdio-agent/src/bin.ts | 5 +- packages/ui/stdio-agent/src/index.ts | 3 + packages/ui/stdio-agent/src/stdio-chat.ts | 23 ++-- .../ui/stdio-agent/tests/built-bin.e2e.ts | 19 ++- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 10 +- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 3 +- packages/ui/user-approval/README.md | 2 +- packages/ui/user-approval/src/index.ts | 3 +- .../ui/user-approval/tests/approval.spec.ts | 2 +- packages/ui/user-interaction/README.md | 2 +- packages/util/brand/src/index.ts | 7 +- packages/util/timeout/src/index.ts | 24 ++-- packages/util/timeout/tests/timeout.spec.ts | 3 +- packages/web/tool-web/src/fetch.ts | 8 +- packages/web/tool-web/src/html.ts | 5 +- packages/web/tool-web/src/index.ts | 7 +- .../web/tool-web/tests/integration.spec.ts | 6 +- packages/web/tool-web/tests/load-path.spec.ts | 8 +- packages/web/web-fetch-local/README.md | 4 +- packages/web/web-fetch-local/src/provider.ts | 13 +- packages/web/web-search-deepseek/README.md | 6 +- packages/web/web-search-deepseek/src/index.ts | 5 +- .../web/web-search-deepseek/src/provider.ts | 7 +- packages/web/web-search-deepseek/src/types.ts | 5 +- .../tests/deepseek.spec.ts | 3 +- packages/web/web-search-exa/src/provider.ts | 4 +- .../web/web-search-perplexity/src/provider.ts | 6 +- .../web/web-search-perplexity/src/types.ts | 3 +- packages/web/web/src/index.ts | 9 +- packages/web/web/src/types.ts | 16 ++- packages/workflow/tool-workflow/src/index.ts | 8 +- .../tool-workflow/tests/tool-workflow.spec.ts | 6 +- .../workflow-workerthread/src/host.ts | 6 +- .../workflow-workerthread/src/index.ts | 5 +- .../workflow-workerthread/src/meta.ts | 4 +- .../workflow-workerthread/src/protocol.ts | 4 +- .../workflow-workerthread/src/realm.ts | 15 ++- .../workflow-workerthread/src/runtime.ts | 13 +- .../workflow-workerthread/src/session.ts | 13 +- .../workflow-workerthread/src/types.ts | 3 +- .../workflow-workerthread/src/worker.ts | 4 +- .../tests/session.spec.ts | 7 +- .../tests/workflow-workerthread.spec.ts | 6 +- packages/workflow/workflow/src/index.ts | 7 +- packages/workflow/workflow/src/types.ts | 5 +- scripts/check-workspace-constraints.ts | 4 +- scripts/demo-code-mode.mjs | 5 +- scripts/doc-typecheck.ts | 12 +- scripts/gen-config-catalog.ts | 17 ++- scripts/gen-cordis-api.ts | 12 +- scripts/gen-cordis-catalog.ts | 12 +- scripts/gen-doc-graphs.ts | 18 +-- scripts/gen-module-graph.ts | 8 +- scripts/gen-persistence-catalog.ts | 22 ++- scripts/gen-tool-catalog.ts | 16 ++- scripts/jsdoc.ts | 25 ++-- scripts/publint-all.ts | 3 +- scripts/rfc-index.ts | 3 +- scripts/verify-doc-budgets.ts | 4 +- scripts/verify-doc-refs.ts | 3 +- scripts/verify-export-jsdoc.ts | 7 +- scripts/verify-md-links.ts | 3 +- scripts/verify-md-wrap.ts | 5 +- scripts/verify-mermaid.ts | 5 +- scripts/verify-package-paths.ts | 5 +- scripts/verify-rfc-classification.ts | 3 +- scripts/verify-rfc-format.ts | 3 +- scripts/verify-scoped-dispatch.ts | 1 + scripts/verify-translation-pairing.ts | 4 +- scripts/verify-type-equiv.ts | 8 +- tsdown.config.ts | 3 +- vitest.config.ts | 6 +- vitest.e2e.config.ts | 4 +- vitest.snapshot.config.ts | 5 +- 392 files changed, 2801 insertions(+), 1754 deletions(-) create mode 100644 .agents/skills/dsh-trim-prose/SKILL.md create mode 100644 .agents/skills/dsh-trim-prose/agents/openai.yaml create mode 100644 .agents/skills/dsh-trim-prose/references/examples.md create mode 100644 docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md create mode 100644 docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md create mode 100644 docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md create mode 100644 docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md create mode 100644 docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md create mode 100644 docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md create mode 100644 docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md rename packages/core/agent-loop/tests/{review-fixes.spec.ts => contract-regressions.spec.ts} (97%) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index cae1c247cd..a758529f27 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -5,13 +5,14 @@ description: Use when reviewing a pull request in the deepseek-harness repo — # Reviewing a DeepSeek-Harness PR -Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. +**This skill is guidance, not a complete checklist.** Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits. ## Sources of truth - [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): repository and package rules. - [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes. - [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. +- [dsh-trim-prose](../dsh-trim-prose/SKILL.md): editorial judgment for comments, docs, prompts, and visible strings. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates RFC](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates. - [RFC index](../../../docs/rfc/README.md): design rationale. Treat disagreement with an RFC as a design discussion, not an automatic veto. - For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md). diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md index 3e6da80c94..6dc4d8c8ec 100644 --- a/.agents/skills/dsh-doc-standards/SKILL.md +++ b/.agents/skills/dsh-doc-standards/SKILL.md @@ -5,7 +5,7 @@ description: 'Use when writing, moving, reviewing, or auditing documentation in # Applying the DeepSeek Harness Documentation Standard -The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers Markdown, JSDoc, and code comments; use judgment rather than treating length alone as a defect. +The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-trim-prose](../dsh-trim-prose/SKILL.md) for editorial judgment and never treat length alone as a defect. ## Sources of truth (read, don't re-summarize) @@ -27,12 +27,12 @@ Run the placement test in the standard's taxonomy table, then check the constrai The audit is a hunt for the standard's slop checklist, cheapest probes first. Establish the PR's current base first; after a retarget or base merge, repeat the audit for prose introduced by the new base rather than relying on the earlier result. -1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' | grep -v '^vendor/' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. -2. Hunt narrated history: `rg -n -g '!vendor' "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts'` and keep only contrasts against a live alternative. +1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers. +2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it. 3. Inspect long comments for reasoning transcripts: control-flow narration, test walkthroughs, proof of obvious branches, review findings, rejected local alternatives, and the same rationale repeated beside sibling methods. Preserve only a non-obvious contract or durable rationale; otherwise delete the comment. 4. Hunt duplication by grepping distinctive phrases. Keep one home and replace other copies with links. 5. Replace hand-written catalogs, test/status inventories, and JSDoc restatements with the authoritative tree, script, or generated reference. -6. In `implemented/` RFCs, remove migration plans, test checklists, and future-tense spec language; keep the decision, rationale, and shipped constraints. +6. In `implemented/` RFCs, remove migration plans, acceptance-task checklists, and future-tense spec language. Keep concise verification contracts that identify the behaviors and tiers pinning the shipped decision, plus named coverage gaps. 7. If removing prose changes a promised behavior rather than its explanation, use a proposed RFC first (follow [dsh-find-simplifications](../dsh-find-simplifications/SKILL.md)). Keep every load-bearing rule, preferably as one to three lines plus a link to its rationale. Cut stories, duplicates, status notes, and the path used to derive the rule. Do not create a new explanation merely to relocate disposable reasoning. diff --git a/.agents/skills/dsh-trim-prose/SKILL.md b/.agents/skills/dsh-trim-prose/SKILL.md new file mode 100644 index 0000000000..afacdc0d7f --- /dev/null +++ b/.agents/skills/dsh-trim-prose/SKILL.md @@ -0,0 +1,71 @@ +--- +name: dsh-trim-prose +description: Use when trimming, restoring, or auditing prose in the deepseek-harness repo, including Markdown, JSDoc, code and test comments, prompts, descriptions, diagnostics, and CLI or UI strings; especially for generated-sounding narration, duplicated explanation, or an earlier edit that may have removed contract detail. +--- + +# Trim DeepSeek Harness Prose + +Preserve the contract while removing reasoning transcripts, repetition, and decoration. This skill owns editorial judgment; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script. + +## Inputs and exclusions + +Require an explicit `scope`. If it is missing, report the required input and stop; do not infer a repository-wide scope or begin an interview. + +Accept `mode: automatic | interactive`; default to `automatic`. Enter interactive mode only when the user explicitly requests questions or calibration. + +Always exclude `vendor/` from discovery, review, and edits, even when the requested scope is the whole repository. Do not follow a symlink into it. Put exclusions after inclusion globs so a later include cannot re-admit it: for example, end ripgrep commands with `--glob '!vendor/**'`, and give Git commands an explicit `:(exclude)vendor/**` pathspec. If the requested scope contains only `vendor/`, report that no eligible files remain. + +Treat generated catalogs, translations, snapshots, and fixtures as derivative. Edit the owning source or scenario first, then regenerate or synchronize the derivative artifact. Follow the bilingual workflow when either side of a documentation pair changes. + +## Preserve the complete proposition + +Before editing, identify every proposition in the passage. Preserve each relevant: + +- actor and action; +- condition, timing, and ordering; +- modality such as must, may, or never; +- negative guarantee and exception; +- ownership, side effect, failure mode, and consequence. + +Remove adjectives, repetition, and narration only when every factual clause survives and the result is clearer. A smaller word count alone is not an improvement. + +Keep a complete local contract at the point of use: behavior, failure, ownership, and consequence that a caller or maintainer needs there. Aggressively link to the owning document for architecture, rationale, algorithms, history, or extended examples. One explanation has one home; essential contract facts may repeat locally. + +Keep non-obvious rationale when omitting it could plausibly cause misuse or an incorrect simplification. Otherwise state the consequence and link the rationale home. + +## Calibrate by prose surface + +- **Public JSDoc:** retain caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability. +- **Internal comments:** retain orientation for non-local structure and obviously complicated local structure. Delete control-flow narration and code restatement. +- **Module comments:** retain the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation. +- **Tests:** retain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories. +- **Cookbooks:** retain prerequisites, required actions, the real entry path, observable verification, and concise warnings. +- **READMEs:** retain the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Link algorithms and design rationale. +- **RFCs:** presume unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps are load-bearing. Implemented RFCs state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision. +- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. +- **Skills and agent instructions:** preserve behavioral guardrails and explicit scope statements such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. +- **Examples and configuration comments:** retain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. +- **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies. +- **Diagnostics:** retain the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. + +Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. + +## Workflow + +1. Confirm the scope, mode, current branch or PR base, and applicable `AGENTS.md` files. Do not inspect unrelated branches. +2. Read [the documentation standard](../../../docs/AGENTS.md) and the owning code or document before judging a passage. For calibration or unfamiliar cases, read [the distilled examples](references/examples.md). +3. Inspect the requested scope, not only the largest files. Use searches and word counts to find candidates, then judge passages semantically. +4. Classify each candidate as keep, trim, restore, restructure, or defer. Apply clear changes; do not manufacture edits to satisfy a deletion target. +5. Update the owner before derivative artifacts. Re-check analogous passages after learning a new rule. +6. Run the narrow relevant checks, documentation gates, `git diff --check`, and behavior tests for visible strings. Verify the final diff contains no `vendor/` path and report any accidental vendor match rather than claiming a clean exclusion history. +7. Report the inspected scope, clear changes, deliberate keeps, deferred cases, and checks actually run. + +## Borderline decisions + +A case is borderline only when at least two versions satisfy the complete-proposition rule but trade accepted principles, and this skill does not already resolve the tradeoff. A new prose shape with one contract-preserving answer is not borderline. + +In automatic mode, apply clear edits and report genuine borderline cases without asking questions. Do not weaken a proposition to make progress. + +In interactive mode, group analogous passages under the governing principle. Present two or three viable versions, recommend one, and state the factual or structural difference. Do not offer inferior distractors. Use the user's requested channel; when calibrating a PR through inline comments, place the recommended provisional version in the diff and attach the alternatives to that exact line. + +After the user decides, distill the principle and versions into [the examples](references/examples.md), without PR history or reviewer narration, and apply the learned rule to every analogous passage in scope. diff --git a/.agents/skills/dsh-trim-prose/agents/openai.yaml b/.agents/skills/dsh-trim-prose/agents/openai.yaml new file mode 100644 index 0000000000..eea00bffcd --- /dev/null +++ b/.agents/skills/dsh-trim-prose/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Trim DSH Prose" + short_description: "Balance concise prose with complete contracts" + default_prompt: "Use $dsh-trim-prose to audit a specified repository scope and trim or restore prose without losing contract details." diff --git a/.agents/skills/dsh-trim-prose/references/examples.md b/.agents/skills/dsh-trim-prose/references/examples.md new file mode 100644 index 0000000000..d1c1e5a90b --- /dev/null +++ b/.agents/skills/dsh-trim-prose/references/examples.md @@ -0,0 +1,127 @@ +# Distilled prose examples + +Use these examples to identify the governing principle, not as text templates. “Balanced” preserves every load-bearing proposition with the least explanation needed at that location. + +## Preserve every factual clause + +**Original:** “The coordinator carefully serializes writes per session, flushes buffered events before disposal resolves, and reports backend failures to the caller.” + +**Over-trimmed:** “The coordinator serializes persistence.” + +**Balanced:** “The coordinator serializes writes per session, flushes buffered events before disposal resolves, and reports backend failures to the caller.” + +Remove decoration and repetition, not propositions. Actor, per-session scope, disposal ordering, and failure visibility are separate facts. + +## Explicit skill scope is functional + +**Over-trimmed:** “Read the sources and use judgment.” + +**Balanced:** “This skill is guidance, not a complete checklist. Use judgment beyond the named checks; documented requirements still apply.” + +**Over-detailed:** Several paragraphs defending why lists cannot replace independent reasoning. + +Keep the explicit limitation because it changes how an agent applies the workflow. Trim repeated persuasion, not the guardrail. + +## A cookbook keeps action and verification + +**Over-trimmed:** “Add tests for the tool.” + +**Balanced:** “Test registration and disposal at unit level, exercise the tool through the real loader path, and add a snapshot when its rendered output changes. Verify the assertion observes the external result rather than the model's report.” + +**Over-detailed:** A walkthrough of every fixture file and assertion already visible in the example code. + +Keep the test tiers, required action, real entry path, and observable verification. Remove fixture narration. + +## Preserve ownership and timing + +**Over-trimmed:** “Provider work is cancelled during teardown.” + +**Balanced:** “The runtime requests provider cancellation before releasing the child scope; the provider remains responsible for joining its workers before disposal resolves.” + +**Over-detailed:** A chronological account of every promise and callback used to implement teardown. + +The actor, ordering, ownership boundary, and completion guarantee are separate factual clauses. + +## Orient complicated code without narrating it + +**Over-trimmed:** “Worker realm support.” + +**Balanced:** “Owns the worker realm and its host bridge. Realm initialization is single-shot; disposal terminates the worker and rejects later calls. See the worker-isolation RFC for the protocol rationale.” + +**Over-detailed:** A paragraph-by-paragraph preview of the classes and helper functions below. + +Keep role, boundaries, and non-obvious lifecycle behavior. Link architecture rationale and let the code show local control flow. + +## Public JSDoc includes failures + +**Over-trimmed:** “Returns the realm global.” + +**Balanced:** “Returns the initialized realm global. Throws if initialization has not completed or the realm has already been disposed.” + +**Over-detailed:** The internal state-machine branches and exact helper calls that lead to each throw. + +Throws and state preconditions are caller-visible contract facts. + +## Keep a concise implementation mapping + +**Over-trimmed:** “Search provider backed by an external API.” + +**Balanced:** “Maps each provider result to the shared search-result shape, preserving the title, URL, and text while omitting provider-only ranking metadata.” + +**Over-detailed:** A field-by-field restatement of the mapping code, including fields with identical names and obvious assignments. + +Keep mapping details that explain an abstraction boundary or intentional information loss. + +## Link rationale while keeping the local contract + +**Over-trimmed:** “Disposal is documented in the lifecycle RFC.” + +**Balanced:** “Disposal aborts the run and waits for provider quiescence. See the lifecycle RFC for ownership and race handling.” + +**Over-detailed:** Repeating the RFC's promise choreography and rejected ownership models beside every disposer. + +Keep the behavior and completion guarantee where callers need them. Link aggressively for the algorithm and rationale; a link cannot replace the local contract. + +## Implemented RFCs retain verification contracts + +**Over-trimmed:** Deleting the entire Testing section because the RFC has already shipped. + +**Balanced:** “Unit tests cover cancellation before and after publication, disposal quiescence, and provider reload. A built-entry smoke covers the real loader path; snapshot coverage is deferred because the transport is process-specific.” + +**Over-detailed:** A file-by-file walkthrough of fixtures and assertions with no additional behavioral distinction. + +Remove migration tasks and test narration. Keep the tiers, behaviors they pin, real entry path, and named coverage gaps. + +## A security boundary may need one concrete example + +**Over-trimmed:** “Mounted plugins share the host's authority.” + +**Balanced:** “Mounted plugins share the host's authority; for example, access to `ctx.bash` permits commands with the host executor's privileges.” + +**Over-detailed:** A list of every service a plugin could misuse and every hypothetical exploit. + +Keep one example when it makes an otherwise abstract boundary operationally clear. + +## Delete reasoning transcripts entirely + +**Over-detailed:** “First the loop checks whether the value is absent. If it is absent, the next branch returns early. Otherwise it continues, which is why the final assertion is safe.” + +**Balanced:** No comment when the code already expresses those branches. If the early return protects a non-obvious invariant, state only that invariant. + +Do not compress a reasoning transcript into shorter narration; remove it. + +## Configuration comments explain what the tree cannot + +**Over-detailed:** “This entry loads the local filesystem provider, followed by the policy plugin, followed by the read, write, and edit tools,” when the adjacent entries already show that order. + +**Balanced:** “Load policy before the model-facing tools so their write and edit calls pass through the read-before-mutation gate.” + +Keep the consequence of order, a surprising scope rule, or a security boundary. Let the configuration show its own inventory. + +## Do not trim for word count alone + +**Current:** “The adapter converts provider errors into the shared error type so callers can handle authentication, rate-limit, and transient failures uniformly.” + +**Shorter but worse:** “The adapter normalizes provider errors.” + +**Balanced decision:** Keep the current sentence unless a link or surrounding contract already carries the failure categories. The shorter version loses the consequence and distinctions without improving structure. diff --git a/AGENTS.md b/AGENTS.md index 643e558c82..495ed58841 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests ## Secrets / .env -Real-API tests read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the environment or gitignored root `.env`. cordis.yml uses `!!js` (never `!js`) for env vars. Never commit credentials. CI e2e self-skips without a key; [docs/testing.md](docs/testing.md) owns the with-key policy. +Real-API tests and demos read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the environment or a gitignored root `.env` loaded by `process.loadEnvFile()`. cordis.yml uses `!!js` (never `!js`) for env vars. Never commit credentials. CI e2e self-skips without a key; [docs/testing.md](docs/testing.md) owns the with-key policy. ## Conventions @@ -95,15 +95,16 @@ Real-API tests read `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` from the - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. - **Capability seams are three packages** — interface / implementation / consumer; don't split preemptively. - **Explicit > implicit at package seams**: defaulting is an explicit `resolve(request): Spec` step in the owning implementation, never a hidden `?? default` inside `run()` (the `dsh-bash` request/spec split is the template). -- **No hardcoded tunables in plugins**: deployment choices are validated `Config` fields changeable from cordis.yml. Protocol constants, external specs, and security invariants stay fixed. +- **No hardcoded tunables in plugins**: deployment choices are defaulted, validated `Config` fields changeable from cordis.yml; a `DEFAULT_*` constant or test seam is not configurability. Protocol constants, external specs, and security invariants stay fixed. - **Misconfiguration fails loud** at load when self-contained, otherwise at the earliest resolvable point; never silently skip a missing referent. - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. +- **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. -- **Validate RFC premises against current code** and amend proposals before moving them to `implemented/`. +- **Validate RFC premises against current code**; friction may expose overreach, so amend proposals before moving them to `implemented/`. - **Testing policy** — [docs/testing.md](docs/testing.md). Transcript changes need snapshots or a PR note. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). -- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces. +- **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces, and schedule any missing harness support before implementation. - **Merge PRs with merge commits**, never squash/rebase or rewrite pushed branches. Put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. @@ -116,13 +117,13 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle, Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class. -Comments and docs record contracts, not the author's reasoning process. Do not narrate control flow, walk through tests, list rejected local alternatives, preserve review history, or restate code; delete an obvious comment and link to the one durable rationale home when more context is needed. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally. +Comments and docs preserve complete contracts and non-obvious orientation, not the author's reasoning process. Do not narrate control flow, walk through tests, preserve review history, or restate code. Keep every factual clause that affects behavior, failure, timing, ownership, or safe use; link aggressively to the owning rationale instead of duplicating it. Use [dsh-trim-prose](.agents/skills/dsh-trim-prose/SKILL.md) for editorial judgment. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally. Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md). ## Editing these instructions -`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep rules self-contained, link high-level docs, and condense before changing the `verify-doc-budgets` ceiling. +`CLAUDE.md` symlinks `AGENTS.md` at root, `packages/`, and `examples/`; edit the real file. Keep each rule self-contained while linking high-level docs. Condense when clarity survives; raise a `verify-doc-budgets` ceiling when the contract genuinely needs more space. ## Vendoring policy diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 68ab8cb9db..5a093adb90 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — The documentation standard -This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for audits; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale. +This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for editorial judgment; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale. ## The tier taxonomy: one home per fact @@ -12,7 +12,7 @@ Each fact has one home: the tier whose job it is. Elsewhere, link to that home; | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`) | Orders specific to that subtree | Repo-wide rules the root file already carries | | [architecture.md](architecture.md) | The system map: services, the loop, extension seams — read before changing `packages/` | Type shapes (→ core-data-structures), per-package detail (→ package READMEs), decision rationale (→ RFCs), implementation-status annotations | | [core-data-structures/](core-data-structures/core.md) | The type catalog: literal shapes and semantics of the spine and seam vocabulary | Behavior narration (→ architecture.md) | -| [rfc/](rfc/README.md) | Decision records: the why and the what-was-given-up; `implemented/` RFCs describe shipped reality in present tense | Migration plans, test checklists, and spec-speak ("should…") once the decision has shipped | +| [rfc/](rfc/README.md) | Decision records: the why, what-was-given-up, and concise verification contract; `implemented/` RFCs describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped | | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — | | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the RFC each guide links) | | Package README | The per-package contract: config, semantics, limitations, extension points | JSDoc restatement, generated-catalog restatement (event/tool tables), other packages' concerns | @@ -31,7 +31,7 @@ Placement test: bug story → postmortem; design rationale → RFC; procedure - **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). -- **Comments and JSDoc state contracts, not reasoning.** Keep non-obvious behavior, constraints, or rationale at the closest public seam. Delete implementation narration, test walkthroughs, review analysis, and code restatement. +- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for the full decision rules and examples. - Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams". ## Wordcount Budgets @@ -44,7 +44,7 @@ When the gate goes red: 2. **Condense** content that belongs here but can be shorter. 3. **Raise** the ceiling only when the words truly need the space; justify the manifest diff in the PR. A too-low ceiling is a budget bug. -Ceilings retain at least 5% headroom and ratchet down after trims. Targets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. +Ceilings are guardrails, not reduction targets. Retain at least 5% headroom; lower a ceiling only when the document's durable contract still has room, and raise it when necessary content would otherwise be deleted. Targets: root `AGENTS.md` ≤ 1,500 words; `architecture.md` ≤ 1,800; each subtree `AGENTS.md` ≤ 600, except this file ≤ 1,250; `packages/README.md` ≤ 600. Review and the slop checklist govern unbudgeted tiers. ## The slop checklist diff --git a/docs/architecture.md b/docs/architecture.md index 84515910b5..a055dff139 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,7 @@ forever: checkpoint persistence and notify idle/running status ``` -The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and strict `{{name}}` variables. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). +The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5d4851128c..1d4fca8929 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -57,17 +57,19 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/acp-agent/src/index.ts:27`](../packages/ui/acp-agent/src/index.ts) +Source: [`packages/ui/acp-agent/src/index.ts:31`](../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), + * agent loop (an app that pre-creates no agents, like the ACP bridge, omits it), * `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and * the explicit model-facing tool order), the `tools` object to the tool registry (its * presentation `mode`), and `skills` to the skill registry/local provider/tool consumer. + * The schema intersects the owners' schemas, which supply defaults for every + * optional input and keep validation from drifting. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ @@ -95,7 +97,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) -Source: [`packages/core/agent-core/src/index.ts:40`](../packages/core/agent-core/src/index.ts) +Source: [`packages/core/agent-core/src/index.ts:46`](../packages/core/agent-core/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -118,7 +120,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:318`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -138,7 +140,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:19`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:21`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -165,7 +167,7 @@ export interface Config extends LocalConfig { Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/bash/bash-sandbox/src/index.ts:23`](../packages/bash/bash-sandbox/src/index.ts) +Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -201,7 +203,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:22`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:23`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` @@ -254,7 +256,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:50`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:49`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -290,7 +292,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-claude/src/index.ts:39`](../packages/hooks/hooks-claude/src/index.ts) +Source: [`packages/hooks/hooks-claude/src/index.ts:41`](../packages/hooks/hooks-claude/src/index.ts) ## `@deepseek-ai/dsh-hooks-codex` @@ -315,7 +317,7 @@ export interface Config { } ``` -Source: [`packages/hooks/hooks-codex/src/index.ts:32`](../packages/hooks/hooks-codex/src/index.ts) +Source: [`packages/hooks/hooks-codex/src/index.ts:34`](../packages/hooks/hooks-codex/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` @@ -342,7 +344,7 @@ export interface Config { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:29`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:30`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -395,7 +397,7 @@ export interface Config { } ``` -Source: [`packages/support/llm-replay/src/index.ts:300`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-repeat-tool-guard` @@ -427,7 +429,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:24`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:27`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` @@ -435,7 +437,10 @@ Source: [`packages/guard/repeat-tool-guard/src/index.ts:24`](../packages/guard/r /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the sandbox runner argv (the bwrap-shaped profile arguments are appended). + * Override the runner argv; bwrap-shaped profile arguments are appended. A + * non-empty override asserts full enforcement and skips built-in selection and + * probing; a broken runner then fails at execution and must be identifiable by + * {@link runnerFailureSignatures}. */ runnerCommand?: string[] /** @@ -452,7 +457,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:17`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:20`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -470,7 +475,7 @@ export interface Config { } ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:21`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:23`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -505,7 +510,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:36`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` @@ -576,7 +581,7 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:33`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:36`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -619,11 +624,11 @@ export interface Config { disposeGraceMs?: number } -/** Fixed response to child permission requests: reject, or first allow option. */ +/** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' ``` -Source: [`packages/subagent/subagent-acp/src/index.ts:17`](../packages/subagent/subagent-acp/src/index.ts) +Source: [`packages/subagent/subagent-acp/src/index.ts:18`](../packages/subagent/subagent-acp/src/index.ts) ## `@deepseek-ai/dsh-subagent-fork` @@ -637,7 +642,7 @@ export interface Config { } ``` -Source: [`packages/subagent/subagent-fork/src/index.ts:24`](../packages/subagent/subagent-fork/src/index.ts) +Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) ## `@deepseek-ai/dsh-subagent-mock` @@ -672,7 +677,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:85`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` @@ -700,8 +705,8 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly. Omitted means - * lexicographic order. See the explicit-tool-order RFC for rationale. + * Shape errors fail at load and unknown names fail at assembly; known names + * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] } @@ -725,7 +730,7 @@ export interface Config { } ``` -Source: [`packages/cordis/tool-cordis/src/index.ts:22`](../packages/cordis/tool-cordis/src/index.ts) +Source: [`packages/cordis/tool-cordis/src/index.ts:25`](../packages/cordis/tool-cordis/src/index.ts) ## `@deepseek-ai/dsh-tool-fs` @@ -745,7 +750,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:30`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:31`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` @@ -820,7 +825,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:19`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` @@ -842,7 +847,7 @@ export interface Config { } ``` -Source: [`packages/web/tool-web/src/index.ts:28`](../packages/web/tool-web/src/index.ts) +Source: [`packages/web/tool-web/src/index.ts:29`](../packages/web/tool-web/src/index.ts) ## `@deepseek-ai/dsh-tool-workflow` @@ -858,7 +863,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:23`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:26`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -867,7 +872,12 @@ Requires: `systemPrompt` ```ts config-catalog /** Plugin config: how the registered tools are presented to the model. */ export interface Config { - /** Model presentation: native schemas, `run_code` plus SDK, or both. Code modes require a TypeScript runtime. */ + /** + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. + * Code modes require a TypeScript runtime and fail prompt assembly when it is + * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + */ mode?: ToolPresentationMode } @@ -875,7 +885,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:300`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:308`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -906,7 +916,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:213`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:214`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` @@ -925,7 +935,7 @@ export interface WebServiceConfig { } ``` -Source: [`packages/web/web/src/index.ts:60`](../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:59`](../packages/web/web/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -975,7 +985,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:39`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:40`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` @@ -1047,7 +1057,7 @@ export interface Config { } ``` -Source: [`packages/workflow/workflow-workerthread/src/index.ts:33`](../packages/workflow/workflow-workerthread/src/index.ts) +Source: [`packages/workflow/workflow-workerthread/src/index.ts:36`](../packages/workflow/workflow-workerthread/src/index.ts) ## Loadable plugins with no config diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 7971d0a45b..86803fdebb 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -80,4 +80,4 @@ The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a too ## Tests every tool needs -Cover argument rejection, result shaping, and HMR disposal. Side-effecting tools also need an agent-loop integration test that asserts session events. Editor presentation needs exact unit coverage plus an ACP snapshot; terminal cards must exercise a client with `terminalOutput: true`. +Cover argument rejection, every result shape, and HMR disposal. For a side-effecting tool, drive the real tool through the agent loop with a scripted `MockAdapter` and assert its `tool/call` and `tool/result` session events. For an editor card, assert the exact `presentCall` and `presentResult` views and add an [ACP snapshot](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md) through the real bridge; a terminal card's scenario sets `terminalOutput: true` to exercise the capable-client path. diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index e206045fad..2904211944 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -15,7 +15,7 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n ### `agent/created` — emit -A fully configured agent and its session were published. Synchronous listener failure vetoes publication; asynchronous failure is reported. +A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog 'agent/created'(this: Scoped, agent: Agent): void @@ -23,7 +23,7 @@ A fully configured agent and its session were published. Synchronous listener fa Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:127`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent left the registry. AgentLoop emits this after driver quiescence; custom Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:135`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:144`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ 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:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:266`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited checkpoint before `step/start` for outside-step surface mutations. Scope Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:191`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,11 +71,11 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca 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:190`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit -Detached, frozen content entered the agent's inbox. +Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. 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:152`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose the frozen session-stable request prefix once per loop instance. Interru Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,11 +119,11 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit -Agent status changed (`idle` ⇄ `running`, or → `disposed`). +Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void @@ -131,7 +131,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:153`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +143,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:223`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:244`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +167,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded. A Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:253`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -187,7 +187,7 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app ### `fs/edit-intent` — waterfall -Single-slot decision: produce the optional version guard for the next FileSystem.editText. +Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins. ```ts cordis-catalog 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> @@ -195,11 +195,11 @@ Single-slot decision: produce the optional version guard for the next FileSystem Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:60`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:59`](../../packages/fs/fs/src/index.ts) ### `fs/observed` — emit -Record that an actor observed a target at a version, after a successful read/write/edit. +Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -207,11 +207,11 @@ Record that an actor observed a target at a version, after a successful read/wri Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:69`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:68`](../../packages/fs/fs/src/index.ts) ### `fs/write-intent` — waterfall -Single-slot decision: produce the write intent for the next FileSystem.writeText. +Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers. ```ts cordis-catalog 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise @@ -239,27 +239,27 @@ Source: [`packages/llm/llm/src/index.ts:39`](../../packages/llm/llm/src/index.ts ### `session/created` — emit -Emitted after session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. +Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit -Emitted once when an announced session leaves the store, including publication rollback. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. +Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:52`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts) ### `session/event` — emit -Post-commit append feed. Observer failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. +Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog 'session/event'(this: Scoped, session: Session, event: SessionEvent): void @@ -267,17 +267,17 @@ Post-commit append feed. Observer failures are logged and contained. Scope-filte Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:61`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:66`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel -Awaited parallel durability checkpoint; dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. +Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) ## `skill/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f36984364d..0c04e59509 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:331`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:335`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -38,7 +38,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:131`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -50,11 +50,11 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:228`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:229`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) -Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Registers one `ctx.bash` implementation. Runtime command failures resolve as BashRunResult; only infrastructure failures reject. Background starts return immediately without a timeout, report completion exactly once while live, and remain cancellable by signal or kill. Output reads are incremental and flag lost buffered data; disposal kills and awaits all tasks. ```ts cordis-catalog abstract resolve(request: BashExecRequest): BashExecSpec @@ -70,11 +70,11 @@ onTaskDone(listener: BashTaskListener): () => void Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) · [BashTask](../core-data-structures/bash.md) · [BashTaskRead](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:36`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:38`](../../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). +Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog abstract run(request: CodeRunRequest): Promise @@ -82,7 +82,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:29`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:31`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.compact` — `CompactService` (abstract seam) @@ -99,7 +99,7 @@ Source: [`packages/compact/compact/src/index.ts:33`](../../packages/compact/comp ## `ctx.fs` — `FileSystem` (abstract seam) -Abstract filesystem provider service. Subclass, implement the seven storage primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```ts cordis-catalog abstract resolve(path: string, opts?: { cwd?: string }): Promise @@ -127,11 +127,11 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:72`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) ## `ctx.sandbox` — `SandboxProvider` (abstract seam) -Abstract process-sandbox service. Subclass, implement confine, and load the subclass as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). +Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. ```ts cordis-catalog abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv @@ -139,7 +139,7 @@ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv Types: [ConfinedArgv](../core-data-structures/sandbox.md) · [SandboxPolicy](../core-data-structures/sandbox.md) -Source: [`packages/sandbox/sandbox/src/index.ts:109`](../../packages/sandbox/sandbox/src/index.ts) +Source: [`packages/sandbox/sandbox/src/index.ts:114`](../../packages/sandbox/sandbox/src/index.ts) ## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -154,7 +154,7 @@ abstract list(): Promise Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:59`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -173,7 +173,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:550`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:560`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -212,7 +212,7 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:210`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:211`](../../packages/core/system-prompt/src/index.ts) ## `ctx.tools` — `ToolRegistry` @@ -229,7 +229,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:351`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:364`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` @@ -262,7 +262,7 @@ async search(request: WebSearchRequest, exec?: WebExecContext): Promise ``` -Source: [`packages/web/web/src/index.ts:79`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:78`](../../packages/web/web/src/index.ts) ## `ctx.workflows` — `WorkflowService` (abstract seam) @@ -272,7 +272,7 @@ Workflow execution seam. Invalid requests throw before publication; a live run i abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:152`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 82579edf5a..3bf437c2ed 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -108,7 +108,7 @@ interface BashExecSpec { The `owner` token is the isolation key: the executor stores it but never interprets it (access policy is the consumer's job), so a background task started by one agent isn't readable cross-session. A required-but-nullable field makes a forgotten owner a visible `undefined` rather than a silently-unowned task. -Trusted in-process plugins use `stdin` and `env` for hook payloads and hook-specific variables. The model-facing bash tool does not expose either because shell syntax already provides equivalent input. This is not a security boundary: `dsh-bash-local` scrubs ambient credential variables, then overlays explicit caller entries. See [the bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). +Trusted in-process plugins use `stdin` and `env` for hook payloads and hook-specific variables. The model-facing bash tool constructs requests from its named schema fields and exposes neither input because shell syntax already provides equivalent power; tests guard against a future `...args` spread. This is request-shape discipline, not a security boundary: `dsh-bash-local` scrubs ambient credentials regardless of these fields, then overlays explicit values already held by the caller. See [the bash stdin/env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index d61de7bf6f..82bf512eeb 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -50,6 +50,6 @@ interface CompactionResult { ## The service -`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction and `compactRegion(...)` for an explicit surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. +`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy. Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 105d9c7f35..f679ac756b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -201,9 +201,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` ### The request envelope: `LlmCallConfig` and the logged header -The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative tool order, and session prefix through `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) and the [reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). +The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through `request/header` snapshots and deltas. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-events-requestheader-and-requestheader-delta) and the [reconstructability RFC](../rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` may replace the frozen call config. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records its result. Requests reaching `llm/stream` are deep-frozen. +`agent/request` receives a frozen call-config seed and may return a replacement. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 50248f9b81..6c6dd3a130 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -1,6 +1,6 @@ # Filesystem -The filesystem stack has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events, and [dsh-tool-fs](../../packages/fs/tool-fs) executes model-facing read/write/edit calls and renders windows. Alternate backends do not change policy or tool schemas. +The optional filesystem capability has four parts: [dsh-fs](../../packages/fs/fs) owns `ctx.fs` and atomic text operations with optional version guards, [dsh-fs-local](../../packages/fs/fs-local) implements local disk, [dsh-fs-policy](../../packages/fs/fs-policy) adds observed-state and freshness rules through events rather than a service, and [dsh-tool-fs](../../packages/fs/tool-fs) directly executes model-facing read/write/edit calls and renders windows. It is outside the agent-loop spine; alternate backends do not change policy or tool schemas. The model is **additive, not subtractive**: `ctx.fs` alone is a complete, unconstrained text-storage seam (`write` unconditionally creates-or-overwrites, `edit` unconditionally replaces literal text). `dsh-fs-policy` is a plugin that *adds* policy on top by deciding the `fs/*` waterfalls; removing it leaves the bare provider rather than breaking the tool, because the tool is not method-coupled to the policy. A deployment that loads `dsh-tool-fs` is expected to also load `dsh-fs-policy` so the default behavior is read-before-write/edit. diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 478dbff467..28093165bd 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -21,7 +21,7 @@ interface SubagentCapabilities { ## The start request -The service validates this request against the named provider's capabilities before `start`. `parent` supplies working-directory, lineage, and depth context. Optional output schema, depth, tool filter, and persona require matching capability flags. In-process backends scope filters and personas to child creation and implement the supported object-rooted output-schema subset with a forced capture tool. +The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv interface SubagentStartRequest { diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 68ae69f9c2..08c289e5e5 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -184,9 +184,9 @@ type PostToolDecision = | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } ``` -Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds from approval, and guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. +Call `next()` for the default or return a decision to short-circuit. Pre-policy may deny or ask; only `allowed-once` proceeds, while a non-grant, missing approval channel or service, or agent-less request becomes a denial. Guards may still impose a final denial. Arguments cannot be rewritten because history, audit, UI, and execution must agree. -Post-policy may replace content or block with corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured error results, so the call fails without ending the turn. +Post-policy may replace content; a block becomes an `isError` result containing its corrective feedback. `tools/result` receives the frozen execution and result after normalization; observers cannot transform them, and observer failures are contained. Unknown and throwing tools both become structured errors (`ToolNotFoundError` maps to `UNKNOWN_TOOL`), so the call fails without ending the turn. ## The structured-output schema subset diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index b047b5ce76..9424b9ed70 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -91,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` registers search and fetch providers and resolves them at execution time, returning disposers and structured selection errors. Providers use platform `fetch`; the local fetch backend owns URL, redirect, size, timeout, and decoding controls while the tool owns presentation. Private-network blocking is deferred, so do not enable `web_fetch` where it can reach sensitive internal targets. +`WebService` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, caps redirects, bytes, characters, and time, revalidates every same-origin redirect hop, and decodes the body; the tool owns presentation. Private-network blocking is deferred, so do not enable `web_fetch` where it can reach sensitive internal targets. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c27e557a37..0f4323efb9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,28 +7,28 @@ 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:127`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:190`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:152`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:143`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:242`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:136`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:144`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:266`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:201`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:213`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:223`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:174`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:153`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:234`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:244`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:253`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:60`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:69`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:51`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:44`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:66`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 325e7cc292..a56f82a44d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ Raw stream chunk — token-level replay fidelity. Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -69,7 +69,7 @@ Assembled assistant message for one step (derived history uses this). Carries th Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) ### `bash/*` @@ -81,7 +81,7 @@ The session's sandbox mode was switched — log-only (like `approval/*`; NOT a s 'bash/sandbox-mode': { mode: SandboxMode } ``` -Source: [`packages/bash/bash/src/session-mode.ts:19`](../packages/bash/bash/src/session-mode.ts) +Source: [`packages/bash/bash/src/session-mode.ts:22`](../packages/bash/bash/src/session-mode.ts) ### `compact/*` @@ -93,7 +93,7 @@ Marks the end of a compaction — log-only, releases the lock. `error` set if su 'compact/end': { turn: number; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:34`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:38`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -103,7 +103,7 @@ Marks the start of a compaction — log-only, holds the lock until `compact/end` 'compact/start': { turn: number } ``` -Source: [`packages/compact/compact/src/types.ts:11`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:15`](../packages/compact/compact/src/types.ts) #### `compact/summary` — log-only @@ -115,7 +115,7 @@ Provenance record of a completed summarization — log-only, no surfaceOp. The s Types: [ContentBlock](core-data-structures/core.md) -Source: [`packages/compact/compact/src/types.ts:18`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:22`](../packages/compact/compact/src/types.ts) ### `context/*` @@ -129,7 +129,7 @@ In-session context injection (file-change notices, subdir AGENTS.md, skill conte Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts) ### `hook/*` @@ -145,19 +145,19 @@ Source: [`packages/hooks/hook-protocol/src/types.ts:19`](../packages/hooks/hook- #### `hook/result` — log-only -Log-only hook outcome paired to `hook/invoked` by `handlerId`. +Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the parsed permission result, `stop` for `continue:false`, or `pass`; exit code may be absent, stderr is bounded, and duration is wall-clock runtime. ```ts persistence-catalog 'hook/result': { turn: number; point: string; handlerId: string; decision: string; exitCode?: number; stderrSummary?: string; durationMs: number } ``` -Source: [`packages/hooks/hook-protocol/src/types.ts:27`](../packages/hooks/hook-protocol/src/types.ts) +Source: [`packages/hooks/hook-protocol/src/types.ts:31`](../packages/hooks/hook-protocol/src/types.ts) ### `prompt/*` #### `prompt/blocked` — log-only -A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked prompt and why. +Durable record of a prompt veto and its reason. It is log-only: the blocked prompt never enters the model-visible surface, including in a mixed batch. ```ts persistence-catalog 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } @@ -165,13 +165,13 @@ A queued prompt an `agent/prompt-submit` listener VETOED — the durable record Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:230`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts) ### `request/*` #### `request/header` — log-only -Full snapshot of the EpochHeader the NEXT request is built under, with the RequestHeaderReason it was recorded whole. +Full EpochHeader for the next request, appended inside its step before dispatch. It is log-only and anchors subsequent deltas. ```ts persistence-catalog 'request/header': { header: EpochHeader; reason: RequestHeaderReason } @@ -181,13 +181,13 @@ Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/ #### `request/header-delta` — log-only -Amendment to the folded EpochHeader: at least one of a SystemDelta, a ToolsDelta, a whole replacement LlmCallConfig (four scalars — not worth diffing), or a whole replacement session prefix (`messagePrefix` — small advisory content, replaced whole; an EMPTY array encodes the transition to "none", mirroring the canonical form's absent field — the loop never produces one in practice: the prefix is composed once per instance and anchored by that instance's snapshot, so this arm exists for codec totality). +Log-only amendment to the folded EpochHeader. System and tools use their delta codecs; config and prefix replace whole, with an empty prefix encoding removal. Writers verify round-trip equality or log a fallback snapshot. ```ts persistence-catalog 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } ``` -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts) ### `steering/*` @@ -201,7 +201,7 @@ Steering content injected between steps of a running turn. Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) ### `step/*` @@ -213,7 +213,7 @@ Closes step `step` of turn `turn`. 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -223,13 +223,13 @@ Opens step `step` of turn `turn` — one model call plus the tool executions it 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `todo/*` #### `todo/write` — log-only -The agent's whole todo list, carried as a full snapshot and replaced wholesale on each write — the current list is the most recent `todo/write` (last-write-wins on replay, no fold). Appended by an owning agent via `session.append('todo/write', { todos })`. +Whole-list snapshot; the latest write wins on replay. It is log-only UI state and never enters derived model history. ```ts persistence-catalog 'todo/write': { todos: TodoItem[] } @@ -251,7 +251,7 @@ The model requested one tool invocation: `name` with the raw `arguments` JSON st Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -263,7 +263,7 @@ One bridged sub-dispatch from a `run_code` program: the parent `run_code` call i Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:23`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:25`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface @@ -275,7 +275,7 @@ A completed tool call's model-facing result, plus an optional tool-private `meta Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) ### `turn/*` @@ -289,7 +289,7 @@ Closes turn `turn` with the TurnEndReason that ended it. The loop fires the awai Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -301,7 +301,7 @@ Opens turn `turn`. `trigger` records what started it — a drained message batch Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) ### `user/*` @@ -315,4 +315,4 @@ A user-visible prompt (queued message drained at turn start). Types: [ContentBlock](core-data-structures/core.md) · [MessageSource](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 1483401835..ec91aee492 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -10,13 +10,18 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | +| [Interactive side sessions and merge-back](proposed/feature/2026-07-08-interactive-side-sessions.md) | 2026-07-08 | +| [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | ### Simplification | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | +| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | +| [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | ### Architecture @@ -198,6 +203,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Return the ACP bridge to one live session per connection](rejected/simplification/2026-06-20-single-session-acp-bridge.md) | 2026-06-20 | | [Truncate interrupted final turns on load](rejected/simplification/2026-06-20-truncate-interrupted-turns.md) | 2026-06-20 | | [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 | +| [Collapse workflows to the exercised foreground core](rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md) | 2026-07-12 | +| [Prune unused skill registry surface](rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md) | 2026-07-12 | ### Architecture diff --git a/docs/rfc/implemented/AGENTS.md b/docs/rfc/implemented/AGENTS.md index 36f45ca40a..639415ba70 100644 --- a/docs/rfc/implemented/AGENTS.md +++ b/docs/rfc/implemented/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Implemented RFCs -These RFCs describe shipped decisions. Follow the repo and docs standards plus the [RFC format](../README.md#the-file-format). +These RFCs describe shipped decisions. Follow the [root instructions](../../../AGENTS.md), [documentation standard](../../AGENTS.md), and [RFC format](../README.md#the-file-format); `verify-rfc-format` gates the lifecycle-specific structure. ## Keep an implemented RFC current with what actually shipped diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index c137ce5cc6..1c22f9b7ca 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log, Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. -In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary. +In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md index c9f07e463e..18923fbb60 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ b/docs/rfc/implemented/architecture/2026-06-11-custom-schema-dsl.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -Tool parameters must reach the model as standard JSON Schema (the wire format), and tool authors deserve typed `execute(args)` without casts. The repo already vendors schemastery (used for plugin Config), so reusing it was the obvious candidate. The user also explicitly preferred per-property `required: true` booleans over JSON Schema's separate `required` array. +Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array. ## Decision @@ -18,4 +18,4 @@ A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required - First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy). - The DSL is deliberately small (string/number/boolean/object/array, enum, default, nested properties/items). Gaps vs full JSON Schema (unions, formats, constraints) are accepted until real tools demand them. -- The InferArgs mapping is regression-tested at the type level (expectTypeOf) after an early optionality bug shipped and was caught by review. +- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug. diff --git a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md index aeea55103d..4539fb40ba 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md +++ b/docs/rfc/implemented/architecture/2026-06-11-event-sourced-sessions.md @@ -12,7 +12,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source Appends are synchronous (the hot path never blocks on I/O); `session/event` is a sync notification; persistence plugins buffer write-behind and drain at the awaited `session/flush` checkpoint fired at every turn end. -Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records what tool dispatch actually used (post-review fix; regression-tested). +Ordering contract: the loop appends to the session *before* emitting the corresponding Cordis event, and the `agent/step-result` waterfall runs before the `assistant/message` append so the log records the message tool dispatch actually used. Regression tests pin that ordering. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md index 35c1917926..01e50da2ff 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md +++ b/docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md @@ -6,8 +6,6 @@ Status: implemented Failures crossed seams as bare strings. A tool error flattened to a text block — name, code, and stack lost — so a future sandbox/retry plugin couldn't tell ENOENT from EACCES, and the model got less actionable feedback than it could. A non-Error throw degraded further: the loop wrapped it in `new Error(String(x))`, dropping any code. And `LlmError` was the only typed error in the system, with no shared base, so there was nothing for a consumer to `instanceof` against generically. -This is the last of the runtime-validation / error-taxonomy pieces and the one the user was most skeptical of, so it was deliberately built **last and in isolation**: the earlier PRs (arg validation, dev invariants) threw plain `Error`s with a `code` field, decoupled from any shared base, so this change is a pure upgrade and is independently revertible without unpicking them. - ## Decision A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every other imports — no new dependency edge): a stable `code` distinct from `message`, `cause` chaining via `ErrorOptions`, and `name` defaulting to the subclass. `isHarnessError` narrows at seams. @@ -21,6 +19,6 @@ A single `HarnessError extends Error` base in `dsh-llm` (the leaf package every - Errors are machine-routable end-to-end: a plugin can branch on `error.code` rather than substring-matching a message. - One base class is imported widely, but it lives in the package everyone already depends on, so the cost is a single import, not a new edge. - `deriveMessages` does not surface `error` into model history — the model still sees the text block; the structured field is for code and replay. -- Reverting this PR returns the earlier errors to plain `Error`+`code` form; nothing else in the stack depends on the shared base. +- Argument validation and dev invariants retain their existing codes and behavior; the shared base adds cross-seam routing metadata without changing model-facing text. diff --git a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md index b768e7d271..0ded28f598 100644 --- a/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -22,4 +22,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -The twin doubles adapter and key-gated e2e maintenance in exchange for continuous seam-neutrality validation and a second implementation example. Their core config shapes align, although reasoning controls differ. A future conformance suite could justify retiring one adapter through a superseding RFC. +The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding RFC. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 4cfa290181..327aa8eac3 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -2,8 +2,6 @@ Status: implemented -> Merges the original proposal and the decision record for one topic. The proposal's full method-surface and write-path detail lives in git history; this records the decision and the durable, contested choices. - ## Problem Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible. @@ -20,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca Key choices recorded here because they are durable, contested, and surprising: - **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log. -- **Append-only; a crashed turn is closed, never truncated.** `load` preserves the contiguous, parseable events of an interrupted final turn and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. +- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) - **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. @@ -33,4 +31,4 @@ Format versioning: the header carries a `version`; `load` rejects any non-curren ## Consequences -Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only / contiguous-seq / lazy-materialization / serializability semantics. This completes [event-sourced sessions](2026-06-11-event-sourced-sessions.md)'s deferred "real persistence backend" and resolves its `TODO(review)` on the event vocabulary: persisting the log freezes its shape, and the `assistant/chunk` fidelity question is answered above (persist verbatim). +Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index f2c26acb37..665063399d 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -24,6 +24,13 @@ Teardown order is load-bearing for durability. The session lifecycle and loop sh Background task ownership belongs to the executor. `BashExecSpec.owner` carries an optional opaque token, `ownerOf(id)` reads it, and `dsh-tool-bash` stamps the calling session token at start. `bash_output` and `bash_kill` reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped. +## Verification + +- ACP disconnect or session close leaves no registered agent or session-store entry, including when `session/load` races teardown. +- Cancelling before a queued prompt starts prevents that prompt from running or absorbing the next prompt. +- Reloading `dsh-tool-bash` does not let another session read or kill an existing background task because ownership remains on the executor. +- Config-created agents remain loop-fiber-owned, so non-ACP demos need not manage handles explicitly. + ## Session owner tokens are unique among live agents The bash owner token relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may prepare privately, but `SessionStore.enter()` rejects duplicate publication and the losing transaction rolls back. `tool-bash` owns the comparison policy; the bash seam stores an opaque `owner` string without interpreting it. diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index b475dd3851..9f83e46b24 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -56,6 +56,10 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. - **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what do we do on failure?) and belongs in its own RFC, not bundled into this type-only pass. +## Verification + +`BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded through the executor, local implementation, and model-facing tool without adding a `dsh-session` dependency. Collections, public parameters, and exported signatures use the applicable brand for `CallId`, `SessionId`, `AgentId`, or `BashTaskId` rather than bare `string`; raw provider, ACP, and model inputs enter through the brand factory instead of scattered casts. + ## Consequences - **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 902dd9c6bf..f3d4178e3b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -36,6 +36,13 @@ Crucially, `hmr` is **not** a stdout-purity footgun the way the console logger i The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a YAML include cannot **encapsulate** the front-door coupling — it can only describe it in a comment and trust every leaf to obey. It also cannot own a `bin`, so the boot glue stayed copied across three `start.ts` files. A package turns "the ACP app never logs to stdout" from a prose warning into a property of the artifact: there is no logger entry in the leaf to get wrong. +## Verification + +- Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone. +- `demo:echo`, `demo:repl`, and `demo:acp` invoke the app-package bins. +- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). +- The ACP replay transcript remains unchanged because the plugin set and load order did not change. + ## Consequences - **The bare-plugin-tree pedagogy.** echo-agent's inlined `cordis.yml` showed every plugin at once; the spine now lives behind a bundle, so seeing the whole tree means opening `dsh-agent-core`. The app package's README carries that teaching weight. diff --git a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md index 41f18f3a22..486d9aafb4 100644 --- a/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md +++ b/docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md @@ -150,6 +150,10 @@ Both mutations are still atomic (the backend's per-target lock is unconditional) This amends — does not reverse — [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md). The four-layer split, the provider contract, and the freshness *policy* are all kept. What changes is the **coupling between the tool and the policy layer**: a mandatory method service became a plugin-owned event gate, and the fs I/O + read windowing moved from `fileContext` up into `dsh-tool-fs`. The split-fs-seam RFC's description of `dsh-tool-fs` injecting `fileContext` and of `fileContext` owning `read`/`write`/`edit` was updated to match in the same change. +## Verification + +Tests pin both paths: without `dsh-fs-policy`, the root tool plugin boots against `dsh-fs-local`, and read, create, overwrite, and unread edit succeed; with the policy, unread edit returns `FS_NOT_OBSERVED` and unread overwrite is gated by `createIfAbsent`. A later intent listener is not reached after the policy decides. Stale edits fail through provider CAS while the policy performs no `stat`; the tool budgets remain one `stat` for read and zero for write or edit on either path. Model-facing schemas remain byte-for-byte unchanged, so snapshots do not change. + ## Alternatives considered - **Keep `ctx.fileContext` as an in-path method service** — the shape [the split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) first landed; rejected because the tool could not run without the policy layer, making policy load-bearing for basic operation instead of an opt-in tightening. diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index f0ab95ca80..4cf055179c 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -12,7 +12,7 @@ The harness extends the agent loop through a Cordis event taxonomy (see [the mic Two problems motivated pinning the semantics down. First, several turn/step boundaries existed BOTH as a durable `SessionEvent` (`turn/start`, `turn/end`, `step/start`, `step/end`) AND as a mirrored `agent/*` emit (`agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end`). A consumer had two sources of truth for the same fact, and every lifecycle change had to update both. Second, the upcoming Hooks subsystem needs ONE coherent, documented surface to subscribe to — a plugin author (and the Claude Code / Codex hook bridges built on top) must know, without reading the loop, whether to listen on a session event or an agent event, and why. -This is the foundational change in a stack that adds a Hooks subsystem; it establishes the vocabulary the later PRs (interception-Decision reshape, the `hook/*` durable log, the bridges) build on. +This vocabulary is the foundation for interception decisions, the durable `hook/*` log, and the Claude Code and Codex bridges. ## Decision diff --git a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md index 050c0f3590..451cce45d3 100644 --- a/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md @@ -8,7 +8,7 @@ Status: implemented The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `/SKILL.md` or `.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack. -This branch deliberately lands the provider capability first and does not add a model-facing `ls`/`list` tool or change skill discovery. The follow-up consumer can validate UX and prompt shape separately, while this PR establishes the backend seam and local implementation. +This decision adds the provider capability without a model-facing `ls`/`list` tool or skill-discovery change. Those consumers require separate UX, prompt, and policy decisions. ## Decision @@ -36,7 +36,7 @@ Broken or disappeared children may be represented as `type: 'other'` without `ve ## Alternatives considered -**Add a model-facing list tool now.** Rejected for this PR. The immediate request is the provider seam, and the user explicitly asked not to change skill loading or other upper layers in this branch. A model-facing tool needs prompt/schema/rendering decisions that should be reviewed separately. +**Add a model-facing list tool with the seam.** Rejected because its prompt, schema, and rendering contracts are independent of the provider primitive. **Keep directory enumeration in each consumer.** Rejected. That would bind product packages such as `dsh-skill` to Node/local filesystem behavior and bypass policy/remote/sandboxed backends. diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 0f1437db84..854807c109 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -24,13 +24,13 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map, then strict rendering rejects unknown, missing, malformed, or duplicate names. A lone unmatched `{{` remains prose, and substituted values are not rescanned. Section names are also unique. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### Persona as the order-0 section -`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path: `renderPrompt(assembly)`. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership @@ -54,6 +54,13 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - Further variables (`date`, platform, git state) — the registry makes each a one-line contribution by whichever plugin owns the fact; none is claimed here. - A config `cwd` for pre-created stdio agents (would let the stdio persona use `{{cwd}}` and partition persistence by real path) — deferred until the session-cwd story is revisited. +## Shipped invariants + +- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. +- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. + ## Consequences - Every fact in the assembled prompt now has exactly one owner, and the hand-maintained tool prose in leaf YAML is gone: loading or dropping a tool plugin no longer means editing any deployment's persona. diff --git a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md index c890475ba3..d95a709ada 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -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. -`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot; `request/header-delta` encodes supported changes. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot when necessary. +`EpochHeader` records the request's non-history state: call config, rendered system prompt, tool schemas, and session prefix, with empty values canonicalized to absence. `request/header` writes a full initial, resume, or fallback snapshot. `request/header-delta` encodes system changes by common-prefix/suffix line trim, tools by name-keyed additions/removals/changes, and config or prefix by full replacement. `foldRequestHeader`, `diffHeader`, and `applyHeaderDelta` are the pure codec. Each loop instance writes a snapshot on its first request to anchor process boundaries. Deltas are only an optimization: the writer verifies round-trip equality and falls back to a full snapshot for unrepresentable changes such as pure tool reordering. -Each step rebuilds the prompt assembly, composes and freezes the session prefix once per loop instance, runs `agent/pre-step`, snapshots derived messages immediately before `step/start`, and folds call config from the logged header. `agent/request` may replace only the frozen config seed; model-visible content must enter through logged channels. The loop then records the owed header event, builds `GenerateOptions` from the prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. +Each step rebuilds prompt assembly. On the instance's first step, `agent/session-prefix` extends a frozen empty seed with request-only opener messages; the result is frozen and cached for that loop instance. `agent/pre-step` then receives the composed prefix before messages are snapshotted immediately ahead of `step/start`. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. `agent/request` may replace only that frozen config seed, while model-visible content enters through logged channels. The loop records the owed header event—the prefix's only durable home—builds `GenerateOptions` from prefix, snapshot, and header, and deep-freezes it while leaving `AbortSignal` live. Per-instance state is only the cached prefix and whether its anchoring snapshot has been written. **`step/start` is the reconstruction boundary.** A step derives messages from events before that sequence. Injection after the snapshot joins the next request, and reentrant appends are rejected during event publication. `agent/pre-step` is the seam for content needed by the current request. Header reconstruction folds through the step's own `request/header*` event, or carries the prior fold when no new header is written. -**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request from the log prefix and folded header, then compares messages and header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. +**Enforcement.** In development, `dsh-invariants` independently rebuilds each loop request through a fresh `Session`, so the live cache cannot vouch for itself, then compares messages and folded header fields at `llm/stream`. Loop requests are identified by their frozen shape and session id; direct one-shots are excluded. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step. ### The MiniCode shape: adopted, with the provenance arrow inverted diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 155cee60f1..30674f8c8b 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -6,7 +6,7 @@ Status: implemented [The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. -The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). +Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). ## Decision @@ -21,7 +21,7 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis ## Alternatives considered -- **Resolving the provider at `apply` time and throwing when absent (a load-order requirement)** — the first implementation, rejected after review reproduced the failure above. Documenting the requirement ("list backends first") would pin a guarantee the Loader does not make. +- **Resolve the provider at `apply` time and throw when absent** — rejected because "list backends first" would claim a Loader ordering guarantee that does not exist. - **Retrying the lookup (poll until the provider appears)** — converges eventually but invents a private readiness protocol beside the one the framework already has (effect registration + disposal); it also cannot notice a provider LEAVING, so HMR would strand a tool whose wording describes a disposed backend. - **Section-only subagent wording, lazily resolved at assemble time** — tolerates any load order too, but moves tool-choice guidance out of the DESCRIPTION, contradicting the ownership rule the prompt-variables RFC establishes (per-tool semantics and when-to-use belong in the description). Reactive registration keeps the description authoritative AND order-free. - **Keying wording off the provider NAME instead of the provider object** — `providerName` is itself config, so a renamed provider silently gets the wrong words; deriving from the resolved provider's own `inheritsParentContext` cannot drift. @@ -29,6 +29,6 @@ The events also complete the seam's vocabulary: `ctx.subagents` is a named regis ## Consequences - Consumers deriving state from a named provider react to `subagent/provider-added`/`-removed` instead of reading the registry at `apply` time; `dsh-tool-subagent` is the reference implementation. -- **Addition fails loud; removal is contained per listener.** An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown. `start()` still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the [events catalog](../../../cordis-catalog/events.md). +- **Addition fails loud; removal is contained per listener.** An addition listener may unwind registration. Removal runs during disposal, so one throwing listener is logged without starving later mirrors or disrupting teardown. `start()` still resolves the provider by name for every run, preventing stale tools from calling a removed backend. See the [events catalog](../../../cordis-catalog/events.md) and [producer/consumer map](../../../event-producer-consumer.md). - **A window where the tool is absent.** Between backend disposal and re-registration (an HMR reload), the model sees no subagent tool. This is the honest state — the alternative is a tool that dispatches into nothing — and the tool registry's `tools/change` emit keeps prompt assembly current. - **Two waiting fibers sharing a `toolName` is an invalid config caught late.** If two loads of `dsh-tool-subagent` name different providers but the same `toolName`, both wait, and whichever provider arrives first registers; the second registration throws only when ITS provider arrives. `TODO(subagent-dup-toolname)` in the plugin records this blast radius; the tool registry's duplicate-name rejection remains the backstop. diff --git a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index d97d21c05b..f09ab214f5 100644 --- a/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -26,7 +26,7 @@ The design can be skimmed as seven choices: | Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result | | Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary | -The rest of this RFC expands those choices in dependency order. It first explains the Cordis mechanics, then scope routing, creation and session commit, tools and prompts, subagents and workflows, and finally the checks that make the reasoning executable. +The rest of this RFC expands those choices in dependency order: Cordis mechanics, scope routing, creation and session commit, tools and prompts, subagents and workflows, then executable checks. The [July 8 RFC](2026-07-08-agent-scope-contexts.md) remains the contributor contract. The separate [subagent composition-controls RFC](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns `persona`, `toolFilter`, and `maxDepth`; this document discusses only how their setup fits the lifecycle. @@ -276,7 +276,7 @@ The service validates provider capabilities and request semantics before calling Spawn and fork share one in-process driver. It creates the child through `parent.ctx`, passes the required signal into the core creation transaction, and installs persona, tool restriction, and structured-output contributions during unpublished setup. -The provider returns only a published run. At handoff, it rechecks cancellation between removing the creation listener and installing the live listener; an abort there disposes the new handle. Parent teardown reaches the child through `parent.ctx`. Provider unload blocks new starts but does not revoke accepted runs. Run disposal cancels the child and awaits ordered `AgentHandle` teardown. +The provider awaits creation and returns only the published run. At the handoff, core creation detaches its creation-only abort listener; the provider immediately rechecks the signal before installing the live-run listener, so an abort in that narrow interval disposes the new handle instead of escaping cancellation. Parent teardown follows the child because the operation belongs to `parent.ctx`; provider unload blocks new starts but does not become a second revocation owner for accepted runs. The run disposer cancels the child and awaits the AgentHandle's ordered teardown. Spawn uses an empty session seed. Fork uses a validated completed-turn prefix. Conversation seeding changes history only and does not import scope, tools, services, or authority. diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md index d32fe71d4a..8a26fc5d7b 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md @@ -24,7 +24,7 @@ Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentRe Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. -The bridge exposes independent ACP config options for sandbox mode and approval policy only when their services exist. Changes validate against the owning vocabulary and enter the session fold immediately during a turn or at the next turn boundary while idle. Session modes are not used because they cannot represent orthogonal controls; model selection remains connection-wide. +The bridge exposes independent ACP config options for sandbox mode and approval policy only when their services exist. Changes validate against the owning vocabulary and enter the session fold immediately during a turn. While idle, a change is overlaid in responses but remains memory-only until the next turn anchors it; a crash therefore reverts to the durable fold. Session modes are not used because they cannot represent orthogonal controls; model selection remains connection-wide. The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. diff --git a/docs/rfc/implemented/feature/2026-06-15-code-mode.md b/docs/rfc/implemented/feature/2026-06-15-code-mode.md index 5325a0344b..950c2b4bd7 100644 --- a/docs/rfc/implemented/feature/2026-06-15-code-mode.md +++ b/docs/rfc/implemented/feature/2026-06-15-code-mode.md @@ -28,17 +28,17 @@ Three decisions, each elaborated in its own section below: **Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. -**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section renders TypeScript declarations for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for stable output. +**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. **Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. -**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. +**Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. ### The run_code tool and the dispatch bridge Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding JSON-normalizes its arguments—rejecting lossy values before dispatch—waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, and logs `tool/code-dispatch`. Successful text becomes a string and non-text blocks become placeholders; tool errors reject the binding promise. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured output and presentation metadata. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. @@ -88,6 +88,13 @@ The SDK instructs the model to write an async erasable-TypeScript body, call too Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, and the bridge does not propagate per-call `additionalContext` until those contracts are designed for Code Mode. +## Testing + +- **Worker runtime:** Real-worker tests cover output and value capture, failure kinds, compute and wall budgets, hostile binding traffic, empty environment, structured-clone fallback, output caps, and disposal to quiescence. A built-package test runs the worker entry under plain Node. +- **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, omitted `additionalContext`, and HMR cleanup. +- **With-key e2e:** A real model composes two bash calls in one program; the test verifies the collapsed request header, correlated dispatch events, resulting file, and curated answer. +- **Snapshot:** The `code-mode-turn` and `both-mode-turn` fixtures pin the SDK section, header tool list, dispatch events, and result card. + ## Alternatives considered **An add-on consumer plugin with zero core changes.** Rejected because `agent/request` is call-config-only under [reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md), while transforming an assembled tool list would have to undo `toolOrder` canonicalization without owning its config and would depend on listener order. Which tools the model is offered, and in which representation, is the registry's single concern: native schemas and the SDK are two projections of one visible store. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 194baa1053..4a6586b159 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -116,3 +116,10 @@ Two failure paths, both documented: - **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. - **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere). + +## Testing + +- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, and compacting closed steps inside one oversized open turn. +- **Loop:** Tests pin one awaited `agent/pre-step` per step between `turn/start` and `step/start`; a surface mutation there lands outside the step and appears in the single derived request. +- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. +- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work. diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index ede9eb155b..e2871532c8 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -60,6 +60,10 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p `dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. +## Testing + +The seam is tested through the real Cordis Loader/export path, which catches the export-shape failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. + ## Consequences - **Recursion.** Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global `toolFilter`; ACP advertises both capabilities off and rejects such a request. The [subagent composition-controls RFC](2026-07-12-subagent-persona-tool-filter-and-depth.md) owns their exact semantics and security limits. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md index 40dbff8d40..0b98adb0de 100644 --- a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -30,6 +30,12 @@ ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `ma The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. +## Testing + +- **Keyless unit/integration:** A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports. +- **With-key e2e:** The backend spawns the real ACP example; its model answers `PONG`, writes `proof.txt`, and the parent verifies the file. +- **Snapshot gap:** Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while `TODO(acp-subagent-replay)` tracks parent replay against a replaying child. + ## Alternatives considered ### Why stay on SDK 0.25.1? diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md index 0789204e78..16c8f6dab7 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-bridges.md @@ -65,4 +65,4 @@ Hooks run in the agent's session workspace, so relative paths target the user's ## Consequences -Matcher semantics, exit-code handling, and merge precedence live in `dsh-hook-protocol`; each bridge only parses config, builds dialect payloads, and maps outcomes. Native plugins bypass the wire protocol and return typed decisions directly. +Matcher semantics, exit-code handling, and merge precedence live in `dsh-hook-protocol`; each bridge only parses config, builds dialect payloads, and maps outcomes. Per-file coverage includes config branches plus end-to-end mappings through a real loop, `dsh-bash-local`, and shell scripts, while a real-Loader smoke guards the package export shape. Native plugins bypass the wire protocol and return typed decisions directly. diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index a242bde2fd..ac28345791 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -27,4 +27,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo ## Consequences -Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. The library's load path is exercised through its bridge consumers. +Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands. diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 273c1bdd37..44fe7f0632 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -12,7 +12,7 @@ A workflow capability family at `packages/workflow/` in the bash seam shape (int ### The script contract (Claude Code-compatible) -A workflow call contains JSON `meta` and a JavaScript `script` body with top-level `await`. Metadata is validated as data and never evaluated. The body receives `agent`, `parallel`, `pipeline`, `phase`, `log`, and `args`; failed children and ordinary stage errors resolve the affected item to `null`. Claude Code's determinism restrictions are deferred with journaling, so compatible bodies may use clock and randomness after moving their meta header into the parameter. +A workflow call contains JSON `meta` (`name`, `description`, and optional `whenToUse`/`phases`) and a JavaScript `script` body with top-level `await` that returns a JSON value. Metadata is validated as data and never evaluated. The body receives `agent(prompt, options)`, `parallel(thunks)`, `pipeline(items, ...stages)`, `phase(title)`, `log(message)`, and `args`. Pipeline stages receive `(prev, item, index)` with no cross-stage barrier; failed children and ordinary stage errors resolve the affected item to `null` and skip its remaining stages. Claude Code's determinism restrictions are deferred with journaling, so compatible bodies may use clock and randomness after moving their meta header into the parameter. One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferred options (`effort`/`isolation`/`agentType`), malformed arguments, schemas outside the supported subset, tripped caps, seam start failures — throws a `WorkflowError` with `fatal: true`, and the combinators RE-THROW fatal errors instead of nulling the item. Without this, a typo'd option dissolves into a `null` indistinguishable from a child failure — the accepted-then-ignored failure mode this repo bans. One addition: the tool's `args` parameter is a JSON OBJECT (a bare list is wrapped as a field) so the wire schema stays honest. @@ -26,11 +26,11 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Why `node:worker_threads`**: each run gets one unpooled worker. A vm context limits the documented script surface, while message-port RPC bridges `agent()` to host-side child loops. The worker prevents synchronous script work from blocking the host, provides a serialization boundary, and permits forced termination after cancellation. `isolated-vm` was rejected because of its maintenance state and deployment requirements. -The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol, and host-owned records preserve the subagent run contract across it. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns the start, cancellation, worker-death, and disposal algorithms. +The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol, and host-owned records preserve the subagent run contract across it. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns the start, cancellation, worker-death, result-precedence, and disposal algorithms. **Meta is data**: the schema-validated `meta` field reaches the seam as JSON and is only shape-validated. The host never evaluates a metadata literal, which would let script-controlled accessors run outside the worker's isolation. -**Value boundary**: `materializeFromRealm` copies outbound values and rejects unsupported JSON shapes, exotic prototypes, cycles, sparse arrays, and non-finite numbers. Data-property copies make `"__proto__"` safe. `args` crosses through `workerData` and is cloned again before exposure. Realm functions are invoked rather than copied, and thrown values use a total renderer so `result` cannot reject. The engine README documents cross-realm errors. +**Value boundary**: `materializeFromRealm` copies outbound values and rejects functions, symbols, nested `undefined`, exotic prototypes, cycles, sparse arrays, and non-finite numbers. Data-property copies make `"__proto__"` safe; getters are read normally and a throwing getter fails loudly. `args` crosses through `workerData` and is cloned again before exposure. Realm functions are invoked rather than copied, and thrown values use a total renderer so `result` cannot reject. Hook errors are host-realm `WorkflowError`s, so scripts branch on `name` or `code` rather than `instanceof Error`, as documented in the engine README. Concurrency, total-agent, item, timeout, and grace limits are validated config. ### The consumer (dsh-tool-workflow) @@ -44,6 +44,10 @@ An output schema makes a schema-valid committed capture mandatory for successful `StructuredOutputSchema` is the raw enforceable JSON-Schema subset in `dsh-tools` (single-string `type`, `properties`/`required`/`additionalProperties`, `items`, scalar `enum`/`const`), and unsupported keywords fail loudly because that wire data becomes the capture tool's parameters verbatim. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#structured-output-commits-only-authoritative-outcomes) owns the assembly, commit, guard, and terminal-stop correctness algorithms. +## Testing + +Worker-side logic runs through an in-process `MessageChannel` so V8 coverage measures it. Unit tests cover script helpers, fatal and nullable failures, JSON boundaries, caps, cancellation, child ownership, and structured output through real loops. A built-lib smoke runs the separately bundled `lib/worker.js` under plain Node, a with-key e2e drives real child agents, and model-facing workflow behavior is snapshot-covered through its owning example. + ## Deferred (documented non-goals of this cut) - **Background collection** (start tool → run id → completion notice → collect), designed alongside bash/subagent background unification. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index c052fee685..afaa845639 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -79,9 +79,15 @@ The answerer routes through the bridge's reverse-map ownership seam described by `dsh-user-approval` owns the fixed dispatch-and-audit mechanism; `dsh-tools` asks and `dsh-acp` answers. Replaceable answerers remain listeners in their channel-owning plugins, so a three-package capability split would add an empty implementation layer. Sandbox executors remain transport-only, and static capability grants remain separate from interactive approval. +### Testing + +- **Unit/integration:** cover first-wins delegation, fail-closed defaults, malformed and throwing answerers, cancellation races and late-answer discard, audit pairing despite observer failures, unbypassable `'never'`, distinct tool-denial reasons, and ACP per-session routing/outcome mapping. +- **Snapshot:** script permission answers through both sandbox escalation branches and pin the `'never'` prompt plus policy-switch notice. Hook-produced asks without a composed answerer remain covered as fail-closed denial. + ## Deferred - **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox RFC](2026-07-06-sandbox.md) § Escalation records the open scope question). +- **A recorded hook-produced ask with a composed answerer** — escalation records the human-prompt wire, while the current hook fixture pins the no-service denial; their combined producer/answerer path remains unit-covered. - **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design. ## Alternatives considered @@ -98,6 +104,7 @@ The answerer routes through the bridge's reverse-map ownership seam described by - Only `allowed-once` dispatches an asked-about action; absent, rejected, cancelled, or failed answer paths deny. - Session ownership routes prompts, policy, and audit events without crossing editor sessions. - Accepted requests append one durable audit pair; the model sees only the resulting tool result. +- A deployment without the service emits no approval prompt or audit events and denies every `ask` at the tool boundary. Costs and accepted limits: @@ -107,8 +114,6 @@ Costs and accepted limits: ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **What happens in a deployment with no answerer at all (headless, CI)?** Every ask falls through the empty waterfall to `unavailable` and the tool call denies with the "no approval channel is available" reason. Fail-closed is the zero-listener default, not a configuration. - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. diff --git a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md index 9915cd91a8..e8ef337206 100644 --- a/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md +++ b/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md @@ -42,3 +42,7 @@ Config plumbing follows the `persona` precedent, and `toolOrder` sits beside it: - 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 + +System-prompt tests cover lexicographic default order, listed/rest placement, provider-order independence, shared names, invalid lists, unknown or reserved names, the canonical pre-waterfall list, and the rule that listener-added tools are not re-sorted. Loop tests pin identical logged and dispatched order across registration permutations, forwarding through agent-core and both apps, deep-frozen requests, and balanced turn failure with no step, header, or adapter call for an unknown configured name. Snapshot replay keeps the full canonical list only in the pinned `text-turn` header; other fixtures continue to use `{{tools}}`. diff --git a/docs/rfc/implemented/feature/2026-07-06-sandbox.md b/docs/rfc/implemented/feature/2026-07-06-sandbox.md index 057d6cde59..51d6c1eebe 100644 --- a/docs/rfc/implemented/feature/2026-07-06-sandbox.md +++ b/docs/rfc/implemented/feature/2026-07-06-sandbox.md @@ -38,6 +38,10 @@ Denied file effects return a marker naming the effective mode. A confining execu ### Design detail +#### Scope grounding + +OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision. + #### The seam: `ctx.sandbox` `dsh-sandbox` owns the vocabulary and the `SandboxProvider` contract: `confine(argv, policy)` returns the argv to spawn INSTEAD of the caller's own — wrapped so the process and everything it spawns run confined — plus the `enforcement` completeness the selected backend achieves, its denial dialect (`denialSignatures`, the stderr substrings that backend's kernel prints on a denied file effect), and its runner-failure dialect (`runnerFailureSignatures`, how the runner ITSELF failing — and therefore the command never running — identifies itself); with no usable backend it throws the fail-closed `SANDBOX_UNAVAILABLE` error, never a silent unconfined passthrough. The vocabulary: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, FILE effects only — network and process visibility are not claimed), `SandboxEnforcement` (`full` / `partial`), `SandboxPolicy` (mode + workspace root). @@ -101,7 +105,7 @@ Sandbox mode is not narrated in the prompt; denial results report the mode when **The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. The bridge advertises one independent `select` per composable knob — `sandbox-mode` (category `mode`) iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with `currentValue` folded from each session's own log, in `session/new` and `session/load` responses. `session/set_config_option` validates against the same closed lists, routes to the domain setter, and returns the complete refreshed state (the spec contract). -**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Until then responses overlay the pending value. A crash discards it, and reload returns the durable fold. +**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Openness comes from log boundaries rather than `agent.status`, and setters do not append from inside a `session/event` listener because that would reorder later observers. Until anchoring, responses overlay the pending value. A crash discards it, and reload returns the durable fold. #### In-process tools @@ -109,6 +113,13 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s FIXME: Revisit this tool-local boundary. The follow-up design needs to determine whether sandboxing becomes a global harness capability that applies uniformly to every tool, instead of expressing in-process enforcement independently at each tool seam. +### Testing + +- **Unit:** pin platform selection, profiles, fail-closed runner classification, per-call facts, escalation validation and outcome text, per-session folds, narrator coalescing, and turn-enclosed config writes. +- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip. +- **With-key:** drive a real model, runner, bridge approval, and disk effect through granted and rejected escalation. +- **Snapshot:** pin config-option wire, mode/policy prompt deltas, notices, and both scripted approval branches. Real denial stderr stays on platform tests because its dialect is runner-specific. + ## Deferred phases Each phase gets its full design when picked up, validated against the code at that time, and lands with unit, real-API e2e, and snapshot coverage at the tiers it touches. @@ -173,8 +184,6 @@ Costs and accepted limits: ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **A command came back with `[sandbox: file access denied under read-only mode]` — did it fail?** It RAN, and the kernel refused a file effect: the denial is a result fact orthogonal to exit code. The teaching forbids retrying around it; the one sanctioned move is the same command retried once with an escalation request. - **How is a BROKEN sandbox told apart from a failing command?** Runner failure outranks denial in classification: a failed run matching the wrap's `runnerFailureSignatures` means the command NEVER ran — foreground re-throws the structured `SANDBOX_UNAVAILABLE` with the runner's stderr line, a background task stamps `sandbox.runnerFailed` and renders its own marker. A broken sandbox can never read as a failing command, and the command never runs unconfined. - **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases). diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index a7d7117aeb..324aa37256 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -45,6 +45,12 @@ Reminders use `additionalContext` with the plugin source, preserving the origina `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:** A real loop with a scripted adapter covers counting and reset rules, untracked transparency, disposal cleanup, per-agent isolation, canonical argument key order, escalation, denied calls, no-agent execution, wildcard escaping, invalid config, and downstream block or replacement decisions at per-file 100% coverage. +- **Snapshot:** The keyless `repeat-tool-guard` scenario makes five identical `todo_write` calls and pins the gentle third-call and detailed fifth-call reminders in both ACP output and the session log. The plugin is loaded in the live example but remains inert in other scenarios. +- **E2e:** None; the plugin is deterministic and provider-independent, and its seam contracts are 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. diff --git a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index bcecbc0dfa..7ea5f4390e 100644 --- a/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) — a new top-level `packages/cordis/` group — and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live cordis runtime it is running inside: inspect it, mount model-written plugins into it, dispose them again. -The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: mounts can reach real bash, filesystem, and web capabilities. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. +The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a mount can call `ctx.bash` to run commands with the host executor's privileges and can reach the real filesystem and web services. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. ### The three tools diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index 37678096ab..aaac6fbd3c 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -44,13 +44,11 @@ The durability requirement was specific: the doc should show the **literal** cur - **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. - **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. -## Process +## Verification lesson -The design was driven entirely by a one-question-at-a-time grilling that walked the scoping decision tree through concrete examples (`BashExecRequest`, `ToolSchema`, `ToolDefinition`, the schema DSL, the presentation types, the session/persistence split) before committing to the spine-vs-seam rule — the rule was the *output* of the examples, not an a-priori axiom. The implementation landed as four commits mirroring the structure of the work: the gate (`e97f94b`), the catalog (`7e33c7b`), the maintenance-guard updates (`53e01a0`), and a review-fix commit (`6da7a0f`). +The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and definitions, the schema DSL, presentation types, and the session/persistence split before adoption. -That last commit is why the process is worth recording: an independent Codex review (gpt-5.5:xhigh) found a real **scan-gap bug** — `verify-type-equiv` only scanned the docs the manifest named, so a type-equiv block added to an *unmanifested* doc was silently skipped, defeating the 1:1 guarantee in one direction. The fix scans every doc in the markdown scope and reports an unmanifested block as an orphan. The same review corrected a `SessionPersistence` surface-listing prose error (`has`/`delete`) and the `doc-sync` command summary. The bug is the point: a drift gate that silently skips part of its input is worse than no gate, and only an adversarial reader caught it. - -This decision shipped in #71 **without** an RFC at the time — the judgment was that the `ts type-equiv` convention was small enough to document in `development.md`. This RFC is the retroactive record: the spine-vs-seam scoping rule and the verbatim-match-over-assignability choice are exactly the kind of "why was it done this way?" decisions a future maintainer would otherwise re-litigate, and its sibling catalog ([generated cordis events + services](2026-06-20-generated-cordis-catalog.md)) does carry an RFC, so the pair should be documented symmetrically. +`verify-type-equiv` must scan the complete Markdown scope, not only manifest-named documents. Otherwise an unmanifested `type-equiv` block escapes the claimed one-to-one check. The gate therefore reports such blocks as orphans. This RFC records that fail-closed scan rule together with the spine-vs-seam and verbatim-match decisions; the generated Cordis catalog has the symmetric design record in [its RFC](2026-06-20-generated-cordis-catalog.md). ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index b38c80b710..e2a1165846 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The loop exposed durable turn and step boundaries through both `SessionEvent` and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. The stdio UI was the only remaining mirror consumer; ACP and persistence already used the session log. +The loop exposed durable turn and step boundaries through both the replayable `SessionEvent` log and live `agent/*` mirrors. Consumers had to choose between two sources for the same fact and reconcile their timing. ACP and persistence already used the log; the stdio UI was the only remaining mirror consumer and already rendered tool calls and results from `session/event`. This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band. @@ -12,14 +12,17 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that also needs an agent id maintains a session-to-agent map from `agent/created` and `agent/disposed`. +Remove `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. Boundary consumers subscribe to `session/event`. A UI that needs an agent label maintains a session-to-agent map from `agent/created` and `agent/disposed`, because the durable `turn/start` carries the turn number but not the agent id. + +The step mirrors had no consumers and were removed first by the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md). That decision retained the turn mirrors for the stdio UI; this RFC removes them after migrating that test REPL to `session/event` and the id map. ## Scope: what is and isn't removed -This decision covers only durable turn and step boundaries. Steering and stream mirrors have separate decisions: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors. +This decision covers only durable turn and step boundaries. `agent/steering` mirrored a control record and `agent/stream-chunk` mirrored the token stream, so each was handled separately: [steering](2026-07-04-remove-agent-steering-mirror.md) and [stream chunks](2026-07-02-remove-stream-chunk-mirror.md). `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, and `agent/queued` remain live lifecycle or control events rather than transcript mirrors; queued input may be cancelled before any durable event exists. ## Alternatives considered +- **Remove `agent/steering` in the same change** — rejected because it was a control-record mirror rather than a boundary mirror. - **Keep turn mirrors for the stdio UI** — rejected because the UI can render `session/event` and recover the agent label from its id map. ## Consequences diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md index 017eb952eb..73645e8fda 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -18,6 +18,10 @@ Remove `ImageBlock`, its map entry, and image-specific branches from adapters, A The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. +## Verification + +No harness `ImageBlock` is constructed outside RFC records. ACP's independent inbound-image rejection remains tested, while adapter, codec, and compaction default branches are covered with plugin-defined block types. + ## Consequences Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve. diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 5205a5872e..2c7626619b 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam and tests. It retains the named Cordis plugin export shape consumed by the app, while keyless Loader smokes cover the composed entry path. +The `stdio-chat` module now lives inside `dsh-stdio-agent` with its runtime seam. Per-file tests cover EOF, rendering, disposal, and piped-versus-TTY behavior without replacing process globals. It retains the named Cordis plugin export shape consumed by the app; an `unwrapExports` assertion and keyless Loader smokes guard both the package and composed entry paths. The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index eabc910f92..72172144d0 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -21,6 +21,10 @@ Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the Unsupported vocabulary can return when a real consumer exists. `durationMs` remains because durable audit timing is useful independently of a current reader. Bridge-specific payload construction stays in each bridge, while shared durable-event normalization belongs in the protocol library. +## Verification + +`HookDialect` contains only Claude and Codex, and `suppressOutput` is absent from source, parsed-field docs, and normalization. `durationMs` remains in events and fixtures with replay scrubbing. The `600_000` and `500` defaults each live once in the protocol library, per-hook timeout overrides still apply, and both bridge suites exercise the library-owned stderr truncation and decision rules. + ## Consequences The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md index db5910665c..7253f8a1fe 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -11,7 +11,7 @@ Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: ## Decision -Hardcode the existing handshake identity at initialization and remove the unreachable config fields and duplicate defaults. Replace `toolKindFor` with neutral `'other'` at both presenter fallbacks. Normal first-party presentations are unchanged; malformed or failed presentations now render an honest generic card instead of inferring a kind from the tool name. +Hardcode the existing handshake identity `{ name: 'deepseek-harness-acp', version: '0.0.1' }` at initialization and remove the unreachable config fields and duplicate defaults. Replace `toolKindFor` with neutral `'other'` at both presenter fallbacks. Normal first-party presentations are unchanged; malformed or failed presentations now render an honest generic card instead of inferring a kind from the tool name. Initialize tests and snapshots pin the handshake; only the malformed calls in `hook-codex-posttool-block` change fallback card kind. ## Alternatives considered diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index c543a147bb..5f05c92317 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -75,6 +75,6 @@ Tool determinism comes from a temporary cwd, scrubbed environment, fresh non-log ## Consequences -The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. In return it provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. +The new tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the temporary cwd for both record and replay. In return the tier provides deterministic keyless transcript coverage through the real Loader and tool composition. The subprocess, input, workspace, normalization, and replay harness can support examples beyond ACP. This RFC relates to but does not supersede the [proposed determinism RFC](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas snapshot tests pin the *external protocol output*. They are complementary — one guards the event-sourcing invariant, the other guards the editor-facing contract. diff --git a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md index 24b5050628..ea2eb6964a 100644 --- a/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/docs/rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -6,7 +6,7 @@ Status: implemented The harness leans hard on real-API tests by policy: [docs/testing.md](../../../testing.md) argues that a no-key suite proves the plumbing but not the product, and the [ACP inject postmortem](../../../postmortem/0001-acp-default-export-drops-inject.md) is the standing proof — 178 keyless tests stayed green while a real editor session crashed instantly. The real-API e2e suite (`pnpm run test:e2e`, the `*.e2e.ts` files) exists precisely to close that gap: it drives the agent against the live DeepSeek API — real model calls, real bash tools, multi-turn, resume, ACP-over-stdio. -But until this change **nothing in CI ran it**. The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless — it carries no secret, runs on every push and PR including from forks, and stays green for any contributor. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so even if ci.yml invoked it, a keyless runner would skip it green. The real-API safety net therefore only fired when a developer happened to run it locally with a key in their environment — i.e. unreliably, and never as a merge gate. +The default gate ([.github/workflows/ci.yml](../../../../.github/workflows/ci.yml)) is deliberately keyless: it carries no secret and runs for forks. `test:e2e` self-skips without a key (`describe.skipIf(!process.env.DEEPSEEK_API_KEY)`), so adding it there would report green without exercising the real suite. A separate secret-bearing workflow is required to make real-API coverage a merge signal. This RFC records the decision to add a **second, secret-consuming workflow** that runs the real-API suite in CI, and — because introducing the first CI secret into a repo that may later go public is a security/isolation decision — the threat model it relies on and what changes when the repo becomes public. @@ -20,11 +20,11 @@ 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 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. +Internal inference cost is not the limiting constraint, so the workflow optimizes for coverage and signal. It runs every matching `*.e2e.ts` file on multiple triggers and every trusted PR, implementing the [docs/testing.md](../../../testing.md) with-key policy. ### Triggers: trusted events only -`workflow_dispatch` + `push` to `main`/`master` + nightly `schedule` (`17 0 * * *`, 08:17 Asia/Shanghai) + `pull_request`. Push gives a post-merge signal; schedule catches drift in the external API itself even with no commits; dispatch is the manual escape hatch; `pull_request` gives a pre-merge gate. The user explicitly chose to include PR runs for the pre-merge signal, accepting the larger key-exposure surface that implies (see § Security). +`workflow_dispatch` + `push` to `main`/`master` + nightly `schedule` (`17 0 * * *`, 08:17 Asia/Shanghai) + `pull_request`. Push gives a post-merge signal; schedule catches external-API drift; dispatch is the manual escape hatch; and trusted pull requests get a pre-merge gate. That pre-merge signal deliberately accepts the larger key-exposure surface described under § Security. ### The untrusted-PR gate @@ -50,7 +50,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. - **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. -- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value, not its length. (An earlier draft echoed `${#KEY}`; dropped as needless metadata.) +- **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length. ### Scope, runtime shape @@ -58,7 +58,7 @@ The job runs only `test:e2e` on Node 24; keyless gates and version compatibility ## Security -Introducing the first CI secret is the part of this change that warrants a recorded threat model, because the natural question — *"can anyone who opens a PR steal the key?"* — has a non-obvious answer, and the answer shifts when the repo goes public. +The repository's first CI secret requires a recorded threat model because access differs between same-repository, fork, and Dependabot pull requests and changes when the repository becomes public. ### Who can reach the secret today (private repo) @@ -69,7 +69,7 @@ So "everyone who could open a PR can steal it" is false: only the write-access s ### The residual exposure the `pull_request` trigger adds -Because PR runs are enabled, the key is handed to **the code on a write-access author's PR branch** — code under review, not yet merged — which is a strictly larger surface than `push`-to-main + `schedule` + `workflow_dispatch` alone (where the key only ever touches already-merged or manually-dispatched code). This was the explicit round-1 tradeoff: the pre-merge real-API gate is worth it for a trusted internal write set and a low-value (internal, free) key. If that calculus changes, the hardening is one line — drop the `pull_request` trigger — keeping post-merge + nightly + on-demand coverage. +Because PR runs are enabled, the key is handed to **the code on a write-access author's PR branch** before merge. This is a larger surface than `push` + `schedule` + `workflow_dispatch`, accepted for a pre-merge signal within the trusted write set. If that calculus changes, drop the `pull_request` trigger while retaining post-merge, nightly, and on-demand coverage. ### What changes when the repo goes public diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md index fd2d13c84f..26c6e95916 100644 --- a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -19,7 +19,7 @@ Replay is keyed **per calling session**, and the harness harvests **every** sess ### 1. The calling session id rides on the model request -`GenerateOptions` gains an optional `sessionId`, stamped by the agent loop from `agent.session.id` at request-assembly time (where the session is already in scope). Adapters ignore it; it exists so an `llm/stream` listener can route a call by WHICH session issued it. It is typed `Branded<'SessionId'>` (from `dsh-brand`) rather than importing `SessionId` from `dsh-session` — that package imports `Message` from `dsh-llm`, so importing its id back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a real id assigns with no cast. (A future dedicated ids package could own the brand and dissolve the note; tracked separately — it touches every id import and does not belong in this testing PR.) +`GenerateOptions` gains an optional `sessionId`, stamped by the agent loop from `agent.session.id` at request-assembly time (where the session is already in scope). Adapters ignore it; it exists so an `llm/stream` listener can route a call by WHICH session issued it. It is typed `Branded<'SessionId'>` (from `dsh-brand`) rather than importing `SessionId` from `dsh-session` — that package imports `Message` from `dsh-llm`, so importing its id back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a real id assigns with no cast. A dedicated ids package could own the brand later, but that change touches every id import and is tracked separately. ### 2. Replay binds live sessions to recorded scripts by first-call order diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 47db2cd793..edfcc18585 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -10,7 +10,7 @@ That is the tier a mocked unit test structurally cannot be: it exercises the REA ## Decision -Two coupled changes, in one PR: +The implementation has two coupled parts: ### 1. The ACP example ships BOTH hook bridges diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index 436b4c1ce6..b3b8831a32 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -27,6 +27,10 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su - **An injectable ACP `Client` factory instead of declarative `permissionAnswers`** — maximally flexible, but it leaks SDK client construction to every consumer and reopens per-example drift in exactly the layer being unified; a declarative queue keeps `input.json` the single scripting surface and stays golden-normalizable. - **Generalize beyond ACP (a transport-agnostic snapshot harness)** — no second transport exists; the harness is ACP-shaped end to end (SDK client, JSON-RPC frames, `session/update` waiters), and a speculative abstraction would be a seam split ahead of any consumer. +## Testing + +Extraction preserved every existing ACP golden byte. The package's `src/` has per-file 100% coverage through a scripted ACP subprocess: harness tests cover every step operation, both expected-error branches, permission selection/fallback/impossible choice, environment forwarding, workspace seeding, and harvest ordering/noise/fallback; suite tests execute replay against committed synthetic fixtures and record against a temporary copy, plus the pure helpers. Two structurally unreachable guards retain reasoned coverage exclusions. The fake agent substitutes the `session/new` cwd into logs, including Darwin's `/var` realpath behavior, matching the real bin. + ## Consequences A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. diff --git a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md index e1792d3ca3..9eb4c585a4 100644 --- a/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md +++ b/docs/rfc/proposed/architecture/2026-06-16-typed-event-schemas.md @@ -6,14 +6,14 @@ Status: proposed The harness models its core vocabulary — content blocks, message sources, finish reasons, turn triggers, turn-end reasons, and session events — as **merge-extensible maps**: a TypeScript `interface` (e.g. `SessionEventMap`, `ContentBlockMap`) that plugins augment via declaration merging, with the public union derived as `Map[keyof Map]`. This is the repo's universal extension pattern, documented in [docs/architecture.md](../../../architecture.md) ("The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`") and relied on by the `defineTool` `InferArgs` DSL and the `assertNever` exhaustiveness convention. -The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. Two concrete consequences surfaced in review of [the session-persistence work](../../implemented/architecture/2026-06-14-session-persistence.md) (#33): +The pattern is **compile-time only**. The types vanish at runtime: there is no schema object to validate an incoming value against, parse untrusted input with, or enumerate at runtime. The [session-persistence contract](../../implemented/architecture/2026-06-14-session-persistence.md) exposes two consequences: 1. **Persistence treats `event.data` as opaque JSON.** The JSONL/SQLite backends `JSON.stringify`/`JSON.parse` each event verbatim; the only runtime guard is `isJsonValue` (round-trip serializability — rejects BigInt, functions, cycles, non-finite numbers, …), NOT structural validation. A corrupted-but-still-JSON event datum (wrong field types, missing fields) round-trips silently and is only caught later, if at all, by a consumer's `switch`. 2. **No runtime contract for plugin-added variants.** A plugin that declaration-merges a new `SessionEventMap` key gets compile-time typing for its own code, but nothing validates that the values it produces match the shape it declared — at the producer, at the persistence boundary, or on reload. -A reviewer asked whether the project should move "all the JSON serialization/deserialization" — and ultimately the event vocabulary itself — to **Zod** (or a similar runtime-schema library), so the durable boundary and the plugin extension points are backed by runtime schemas rather than erased types. +This raises whether the event vocabulary should move to **Zod** or another runtime-schema library so durable and plugin boundaries have runtime schemas rather than erased types. -This RFC scopes that question. It does **not** propose an implementation; it records the tradeoff so the decision is made deliberately rather than incrementally inside a persistence PR. +This RFC scopes that question without proposing an implementation. ## Why this is not a persistence change @@ -32,7 +32,7 @@ A migration of the event/vocabulary surface to runtime schemas touches, at minim - **The `defineTool` `InferArgs` DSL** (`dsh-tools`), which derives zero-cast `execute` arg types from a compile-time schema spec — the showcase of the current approach. - **Docs**: architecture.md (the pattern is described as foundational), [dev-mode invariants](../../implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md), and any RFC that references the pattern. -This is a HUGE change. It is not in scope for the RFC-009 session-persistence work and must not be smuggled in through it. +This is a repository-wide vocabulary redesign, not a persistence implementation detail. ## Alternatives considered @@ -46,7 +46,7 @@ Keep the compile-time pattern. Persistence stays opaque-JSON + serializability g Tighten only the genuinely-closed shapes that already have hand-rolled type guards — e.g. the JSONL `HeaderLine` guard (`isHeaderLine`) — using **schemastery** (the repo's existing schema library, already used for every plugin `static Config`). Leave the merge-extensible event union as-is. - **Pros**: small, fits the existing convention (schemastery, not a new lib); replaces hand-rolled guards on closed shapes with declarative schemas; no core redesign. -- **Cons**: does not address event-data validation (the thing the reviewer actually asked about); only helps the fixed metadata records. +- **Cons**: does not address event-data validation; only the fixed metadata records improve. ### C. Runtime schema registry for the whole vocabulary (Zod or schemastery) Replace the merge-extensible maps with a runtime registry the producers contribute to and the persistence/consumer paths validate against. @@ -56,11 +56,11 @@ Replace the merge-extensible maps with a runtime registry the producers contribu ## Proposal -Defer. Do **not** change #33. If runtime validation is wanted at the durable boundary in the near term, **Option B** (schemastery on the closed header/metadata shapes) is the proportionate step and stays within the existing convention. **Option C** is a genuine architecture decision that should be evaluated on its own merits — including whether the chosen library is Zod or schemastery — and, if accepted, land as its own change with its own RFC, not as a side effect of persistence serialization. +Defer. If runtime validation is wanted at the durable boundary, **Option B** (schemastery on closed header and metadata shapes) is the proportionate step within the existing convention. **Option C** is an architecture decision that requires its own implementation RFC, including a choice between Zod and schemastery. ## Acceptance criteria -- The decision state is explicit: Option C proceeds only as its own change with its own implementation RFC — never as a side effect of a persistence PR. +- Option C proceeds only through its own implementation RFC, never as a persistence side effect. - If Option B is taken up, the closed header/metadata shapes (the JSONL `isHeaderLine` guard and kin) validate through schemastery in place of hand-rolled guards, with the merge-extensible maps untouched. ## Risks diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 5d8601a1e1..bf4e85e020 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Add isolated subagent providers for Claude Code and Codex. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. +Add isolated subagent providers for Claude Code and Codex. The existing [named-provider seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) and [ACP backend](../../implemented/feature/2026-06-22-acp-subagent-backend.md) establish the process-boundary shape. A harness turn should be able to delegate a self-contained task to either product and receive its final answer without exposing parent secrets or inheriting host configuration from `~/.claude` or `~/.codex`. ## Proposal @@ -14,7 +14,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. - `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, and a non-rejecting `result` that maps child failures to stop reasons while logging the original error. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. +Both providers follow the ACP backend contract: a fresh child per `start`, one prompt round-trip, no inherited parent context or advertised optional capabilities, ignored `request.parent` and `request.agentOptions`, and a random branded agent id. `result` never rejects; child failures map to stop reasons while the original error reaches the logger. Each mounts `dsh-tool-subagent` under a distinct tool name. The tool result is the only new model-visible artifact, so no new session event is required; workspace mutations remain ambient side effects outside transcript replay. ## Verified interface facts (pinned versions) @@ -31,11 +31,11 @@ Both integration surfaces were verified against pinned implementations before th ## Isolation and credentials -Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary environment variables, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start`. +Authentication is API-key-only. Each run uses a fresh config directory (`CLAUDE_CONFIG_DIR` with `settingSources: []`, or `CODEX_HOME`) that is removed best-effort on dispose; config may instead select a persistent directory. The shared child-env helper forwards ordinary values such as `PATH`, `HOME`, `TMPDIR`, locale, and proxy settings, removes credential-shaped names, and overlays explicit `config.env`. Claude Code receives its API key through that overlay, while Codex receives it through `account/login/start` rather than a hand-written auth file. ## Permission and approval policy -Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with rejected fallback permissions; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Every server request is answered programmatically, including unknown methods, so a child cannot wait indefinitely for unavailable human input. +Each backend exposes its engine's native policy vocabulary. Claude Code defaults to `permissionMode: default` with `permission: reject`; Codex defaults to `sandboxMode: read-only`, `approvalPolicy: never`, and the same rejected fallback. Examples opt into `acceptEdits` or `workspace-write`. Known approval, user-input, and elicitation requests receive the configured answer; unknown methods receive method-not-found and unknown notifications are consumed. No prompt reaches a human, and no child can wait indefinitely for unavailable input. ## StopReason mapping @@ -47,9 +47,9 @@ Liveness posture, stated explicitly: teardown timing is config, turn duration is Coverage is required at each applicable tier: -- **Keyless unit/integration:** use scripted child processes through the real SDK or wire client to cover round trips, stop mapping, cancellation, permissions, isolation, failures, and cleanup. -- **With-key e2e:** each real engine performs file work under an explicitly writable policy; skips name the missing binary or key. -- **Snapshot:** deferred under provider-specific TODOs pending the per-session replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). +- **Keyless unit/integration:** drive a fake Claude CLI through the real SDK and a scripted Codex app-server through the real wire client. At per-file 100% coverage, exercise round trips, every stop mapping, both cancellation paths and pre-abort, permission policies, unknown messages, spawn failure, reload cleanup, export shape, scrubbed environments, temporary-directory removal, and Codex auth precheck failure. +- **With-key e2e:** each real engine performs file work under `acceptEdits` or `workspace-write`; skips name the missing binary or key and assert no child process remains. +- **Snapshot:** deferred as `TODO(claude-code-subagent-replay)` and `TODO(codex-subagent-replay)` pending the process-specific replay shape described by the [subagent replay RFC](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). ## Alternatives considered diff --git a/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md new file mode 100644 index 0000000000..49735ecc19 --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-08-interactive-side-sessions.md @@ -0,0 +1,39 @@ +# RFC: Interactive side sessions and merge-back + +Status: proposed + +## Problem + +A user may want to explore a question from a live session without changing its main context. Existing primitives do not expose that product shape: [session-store fork](../../implemented/feature/2026-06-30-session-store-fork-api.md) creates an unattached session, while [fork subagents](../../implemented/feature/2026-06-21-subagent-capability-seam.md) are model-driven tasks whose transcript collapses into one tool result. Neither gives the user a separate conversation, and neither records a conclusion back into the parent with provenance. + +## Proposal + +A **side session** is an ordinary live session forked at the source's last completed turn, attached to its own agent, framed as a read-only advisor, and able to **merge back** one condensed note. + +- **Fork and attach:** create the child with the parent's balanced completed-turn prefix and stamp `parentSession` and `seedLength`. This composes `ctx.agents.create({ seed, meta })`; it adds no core service or session-store method. +- **Advisor framing:** inject one plugin-sourced `context/message` after creation. Keeping the system prompt byte-identical preserves the provider prefix cache. +- **Merge-back:** ask the child for a length-capped handback, then inject one plugin-sourced `context/message` into the parent. The next parent request sees it at its logged position, preserving replay and [request reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) without a new session event. +- **Presentation:** invocation, session switching, and handback rendering belong to the first client-owned surface. This RFC specifies only the surface-independent mechanics. + +Rewind productization, session-tree views, a model-facing side-session tool, and `forkName`/`mergedInto` metadata are out of scope. A live-adapter spike has validated source-log isolation, inherited context, a multi-turn child exchange, and merge-back visibility in the parent's next turn. + +## Alternatives considered + +- **Use the subagent seam:** rejected because side sessions are user-driven, client-visible, and may outlive a parent turn; subagents are model-driven runs returning one tool result. +- **Change the child system prompt:** rejected by default because any byte change invalidates the prefix cache from token zero. Deployments may still prefer that stronger separation. +- **Add `sidechat/*` events:** deferred because a sourced `context/message` already provides durability, provenance, and replay. A dedicated event is justified only by a surface that needs distinct rendering. +- **Bind a protocol surface now:** rejected because current UIs are client-owned. Live presentation must eventually derive from the durable message so replay renders the same record. + +## Acceptance criteria + +- Forking leaves the source untouched and creates a child with the balanced completed-turn prefix, `parentSession`, `seedLength`, and a byte-identical system prompt. +- Advisor framing adds exactly one plugin-sourced `context/message` at the head of the child's appended history. +- Merge-back adds exactly one length-capped `context/message` with source `plugin: sidechat`; the next parent request and replay see it at the same position. +- Parent and child run concurrently without log or stream cross-talk. +- Unit tests cover fork/attach and merge-back; snapshot coverage lands with the first bound surface. + +## Risks + +- Read-only behavior is advisory until a `tools/pre-execute` deny gate enforces it; [the interception seam](../../implemented/feature/2026-06-30-interception-seams.md) can add that gate without changing these mechanics. +- A compacted source forks its compacted view, so a bound surface should disclose that the child inherits summaries rather than replaced turns. +- Repeated handbacks consume parent context. The per-merge length cap bounds each note; later consolidation belongs to compaction. diff --git a/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md new file mode 100644 index 0000000000..200ed50b1e --- /dev/null +++ b/docs/rfc/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md @@ -0,0 +1,41 @@ +# RFC: Stream workflow progress through tool calls + +Status: proposed + +## Problem + +The workflow engine intentionally emits balanced `workflow/*` observation events for run, phase, narration, and child-agent progress, but no production consumer presents them. Editors therefore show one pending workflow tool card until the final result even while the engine already reports which phase is active, what the script logged, and which children started or settled. The [dynamic-workflows decision](../../implemented/feature/2026-07-05-dynamic-workflows.md) explicitly reserves ACP progress UI for this event stream. + +Making `dsh-acp` listen to workflow events directly would invert the capability boundary: the generic UI bridge would depend on an optional workflow package and special-case one tool name. The tool pipeline already owns the routing facts a live update needs—agent and call id—but exposes only pure pending/final presenters, so a long-running tool has no provider-neutral way to report transient UI state between them. + +## Proposal + +Add a live progress channel to `dsh-tools`. The registry-owned `ToolExecution` gains `reportProgress(view): boolean`, where `view` is a detached provider-neutral generic progress snapshot containing an optional replacement title and UI-facing content blocks. Progress cannot change the call's args-derived card tag, kind, raw input, locations, terminal intent, or diff intent; it updates only the live title/content within the presentation chosen up front. While the execution is active, the method validates and snapshots the view, then dispatches a contained, agent-scoped `tools/progress` observation carrying the authoritative execution identity and snapshot. Once final-result processing begins it returns `false` and emits nothing, so a late asynchronous reporter cannot overwrite a terminal card. Observer exceptions are logged and cannot fail the tool. + +`dsh-acp` consumes `tools/progress` generically. It resolves the execution's agent through its existing agent-to-session map and emits an in-progress `tool_call_update` for the same call id. Because reporting is available only inside the tool execution pipeline, the durable `tool/call` and its ACP `tool_call` always precede the first update; closing the reporter before `tools/result` ensures no progress update follows the completed/failed card. Progress is live UI state rather than model input or durable history: session replay continues to reconstruct the pending and final cards from `tool/call` and `tool/result` without replaying transient updates. + +`dsh-tool-workflow` becomes the first producer. Each tool execution installs a compact event capture before calling `ctx.workflows.start()`, because a valid engine may emit progress synchronously inside `start()`. Until the call returns, the capture reduces observed events into candidate states keyed by `WorkflowRunInfo.id`; it then selects the returned `WorkflowRun.id`, discards other candidates, reports the accumulated snapshot, and routes later matching events directly. If `start()` throws, the capture is disposed and its candidates are dropped. This preserves engine swappability without adding observer correlation to `WorkflowStartRequest` or requiring progress to wait until `start()` returns. + +The reducer consumes the existing start, phase, log, agent-start, agent-end, and end events, reporting a replacement snapshot with the current phase, latest log line, active child labels, and completed/failed/cancelled counts. It does not accumulate a narration transcript; settled children leave the active set and become counters. `workflow/end`, tool settlement, or plugin disposal removes the reducer entry and event capture. The six workflow events, their metadata, paired child lifecycle, run handle, cancellation channels, and observer containment remain unchanged; third-party observers can continue consuming them directly. + +Update the tool execution/presentation docs, generated event and API catalogs, workflow package docs, and the workflow data-structure catalog. ACP integration coverage must exercise the real workflow tool and worker seam with a scripted model boundary; the primary ACP snapshot suite adds one workflow-progress scenario because this changes the editor-facing transcript. + +## Alternatives considered + +**Delete the workflow observation surface.** Rejected in [the collapse-workflow simplification](../../rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md): the events and their balanced lifecycle are intentional, and the missing piece is a consumer. + +**Teach ACP about workflows directly.** This could map `WorkflowRunInfo` to a session and card, but it would make the generic bridge depend on an optional capability and bypass the rule that tools own presentation intent. A tool-progress channel solves the same routing problem for every long-running tool. + +**Persist every progress update as a session event.** That would make live narration replayable, but it would permanently enlarge logs with state whose authoritative durable outcome is already the tool call/result pair. If resumable workflow progress becomes a product requirement, it needs a workflow-journaling design rather than UI snapshots disguised as durable facts. + +## Acceptance criteria + +- `ToolExecution.reportProgress()` is registry-owned, agent-scoped, snapshotting, observer-contained, and returns `false` without dispatch after terminal processing starts. +- ACP routes progress to the correct call in the correct live session; concurrent workflows in different sessions cannot cross-talk, and no `tool_call_update` appears before its `tool_call` or after its terminal update. +- Workflow progress shows the current phase, latest log line, active children, and outcome counts while preserving all existing `workflow/*` events and run semantics; a seam test engine that emits start, phase, log, child, and end events synchronously inside `start()` loses none of that reducer state. +- Cancellation, worker death, tool failure, session close, and plugin disposal release reducer state; replay emits only the durable pending/final card pair. +- Unit, workflow integration, ACP integration, snapshot, typecheck, coverage, doc-sync, module-graph, build, and hygiene gates pass. + +## Risks + +This adds a public live-progress method and event to the tool seam, so implementations must keep the active/terminal boundary exact and detach snapshots before observers see them. The pre-start capture can briefly observe unrelated workflow runs, so it holds only compact candidate state keyed by run id and drops every non-matching candidate as soon as `start()` returns. A workflow can emit many progress changes; the bounded reducer avoids transcript growth but still sends one UI update per meaningful event after correlation. If measured clients need coalescing, it must be a defaulted validated bridge configuration rather than a hardcoded throttle. Transient progress intentionally disappears on replay, so the final tool result remains the only durable workflow card content. diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index f7af722663..d54e389ad3 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -4,57 +4,37 @@ Status: proposed ## Problem -The agent factory carries TWO ids for what is, in every live consumer, one thing: +The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced and persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. -- `agentId` — the `AgentRegistry` handle (the actor identity; the registry rejects a duplicate). -- `sessionId` — the event-sourced session / persisted-log identity (`session.header.id`). +ACP already uses the same value for both identities. They diverge for config-created agents, resumed sessions, and in-process children, but no production path reattaches one live agent to several sessions or drives one session through several agent ids. Stdio keeps a session-to-agent map only to recover a display label, and hooks must expose or reconcile both values. -`CreateAgentOptions` takes both separately; `ResumeAgentOptions` takes an `agentId` plus a `resumeSessionId`. They diverge in exactly three places: +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no identity-specific reservation state: create and resume use one transaction, and both registry entries arbitrate at final publication. Unification therefore changes API and representation, not rollback or quiescence. It also makes the live-agent registry enforce the session identity used by background-task ownership instead of relying on callers to preserve that association. -- **Config-driven create** (`AgentLoop.create`): a stable `agentId` (e.g. `"echo"`) with a fresh per-run `sessionId` (`${id}-session-`). -- **Resume**: a caller-supplied `agentId` (e.g. `"main"`) on a persisted `resumeSessionId`. -- **In-process subagent children**: the backend mints the child's `agentId` and `sessionId` as two independent UUIDs (`packages/subagent/subagent-inprocess/src/index.ts`) that nothing distinguishes — `parentSession` records lineage independently. - -Live consumers need no id translation. ACP already uses the session id as the agent id, hooks resolve children directly, and only config-created or in-process agents mint cosmetic differences. Stdio maintains a reverse map solely for labels; unification removes it and gives hooks one identity to report. - -The separation is **latent generality no consumer exercises**: nothing reads a *stable* `agentId` back across runs (each process starts fresh, and persistence keys off the session id, never the agent id). The config path's "stable agentId, fresh sessionId" buys nothing concrete — it is cosmetic. And the `agentId !== sessionId` case is precisely what opens the bash owner-token alias hole: the bash completion-notice routes by `session.header.id`, but the registry enforces uniqueness only on `agentId`, so a programmatic caller registering two agents with different agent ids but the SAME session id can mis-route a notice (see [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) § Seam precondition). The current code documents this as a precondition rather than guaranteeing it. +`Session` separately exposes `Session.id` and `Session.header.id` even though construction requires them to match. The durable boundary must validate the duplicate, and consumers must choose between two homes for one fact. ## Proposal -Make an agent BE its session: one id. An agent's registry handle IS its `session.header.id`. +Use one id for the agent registry entry and `session.header.id`. `CreateAgentOptions` accepts one identity for both entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; and `Session` keeps one identity home. Preserve the current transaction, collision checks, rollback, quiescence, and entry-bound detach semantics. -- `CreateAgentOptions` drops the separate `sessionId` — the single `id` is both the registry handle and the live/persisted session id. (ACP already passes the same UUID for both, so its call site simplifies to one field.) -- `ResumeAgentOptions` drops the separate `agentId` — resuming `sessionId` X registers the agent under id X. (ACP already does this.) -- The config path (`AgentLoop.create`) uses its configured `id` directly as the session id, applying whatever resume-or-create policy it adopts (today it appends a per-run uuid to avoid colliding with an on-disk log; that policy moves onto the single id, e.g. the config id IS the session and a durable backend resumes it — to be settled in the implementing PR). -- The registry's existing unique-`agentId` check becomes, by construction, a unique-session-id guarantee — the bash alias hole is closed with NO new defensive invariant: two agents cannot share a session id because the session id is the agent id. +The config-driven path must first settle its resume-or-create policy. Today it uses a stable agent label and a fresh UUID-suffixed session id to avoid colliding with an existing durable log. Under unification it must deliberately resume a fixed id, mint a fresh combined id, or expose that policy; implementation must not choose silently. + +`agent/created` and `agent/disposed` remain outside this proposal. They are publication lifecycle events rather than identity aliases; removing them requires a separate consumer audit and decision. ## Alternatives considered -### Why not just enforce session-id uniqueness in `AgentRegistry.register()`? - -That was the review's first suggestion. It would couple the generic registry to a session-uniqueness assumption (the registry tracks *agents*, not sessions) and entrench the very separation this RFC removes. Unifying the ids closes the hole more cleanly — there is nothing left to enforce. +**Keep separate routing and log identities.** A stable configured agent label paired with fresh conversations is a real use of the distinction. If that display or routing identity is required, keep the ids separate and enforce session-id uniqueness explicitly instead of hiding the translation in another map. ## Acceptance criteria -- `ctx.agents.create`/`resume` take a single id; the ACP bridge passes one id. -- The config-driven agent path has a deliberate, documented session-id policy (no silent per-run id divergence that no consumer reads). -- The bash owner-token alias hole is gone by construction (no two live agents can share a session id). -- All existing behavior the tests pin (ACP create/resume/load, config startup, durability) still holds — or the tests change WITH the behavior where the divergence was an artifact (per AGENTS.md "tests document behavior, not golden truth"). +- Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence guarantees without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session-id translation. +- The config-driven resume-or-create policy is explicit and covered across a durable restart. +- `agent/created` and `agent/disposed` change only after a separate production-consumer audit. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks -This touches public factory interfaces (`CreateAgentOptions`, `ResumeAgentOptions`, `AgentFactory`) and the config-agent id scheme, so it is a deliberate cross-package change, not a local patch — it ships as its own PR (converged with Codex); the bash owner-token precondition it closes is documented in [agent lifecycle and ownership seams](../../implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). +Unification forecloses a stable actor identity spanning several session logs, including a future handoff or fork that deliberately preserves the actor while changing the session. Reintroducing that design would require a new explicit actor identity. It also makes a persisted, possibly client-chosen session id the registry handle and changes every create/resume call site and fixture. -The genuine risks of collapsing the two ids into one (the case AGAINST this proposal — to be weighed honestly before implementing): - -- **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. - -- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) - -- **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. - -- **Persisted/on-disk identity becomes the agent identity.** Unifying means the registry handle is now a persisted, externally-meaningful string (a session id a client chose), not an internal label. A caller that previously used a short human label (`"main"`) as the agent id now must use the session id. This is fine for ACP (already a UUID) but is a semantic narrowing for any programmatic embedder that relied on naming its agents independently of session storage. - -- **Migration churn touches every create/resume call site and its tests.** `CreateAgentOptions`/`ResumeAgentOptions` shape changes ripple to ACP, the config path, the agent-loop factory, and ~dozens of test fixtures that currently pass distinct `agentId`/`sessionId` (some deliberately distinct to exercise the divergence — those tests change WITH the behavior, per AGENTS.md "tests document behavior, not golden truth"). The risk is mechanical but broad; a missed call site is a type error, but a missed *test* could silently lose coverage of a path. - -The one real design question the implementing PR must settle first is the config-driven resume-or-create policy once the id is unified (today's per-run-uuid behavior is a demo simplification already flagged `TODO(demo)`). If, on closer look, the fork/spawn or multi-session-actor futures turn out to be wanted, this RFC should be REJECTED in favor of the lighter "enforce session-id uniqueness in the registry" guard — the alias hole is not reachable via ACP, so keeping the ids separate and merely documenting (or mechanically enforcing) the precondition remains a valid alternative. +The config restart policy is the blocking design decision: a fixed combined id may collide with its existing log, while a per-run id gives up the stable configured label. If either independent actor identity or the stable-label/fresh-session pairing is a real requirement, reject this proposal and retain the separate ids with an explicit uniqueness guard. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index e28c5b96c0..cca7c57b34 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -1,32 +1,60 @@ -# RFC: Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId` +# RFC: Prune dead public and result surface Status: proposed ## Problem -Three pieces of public spine surface share one defect class: their only possible role is to be ignored, or their trigger is unreachable. +Several package-root exports, result fields, and convenience methods have no production consumer. They survive because tests import internals through public entry points or because a type anticipated a caller that never arrived. Each item is small in isolation, but together they enlarge the SDK contract, generated catalogs, documentation, and regression matrix without enabling a shipped path. -1. **`SurfaceManager.invalidate()`** (`packages/core/session/src/surface.ts`). Its documented trigger — "the log has been replaced wholesale (e.g. after Session seed)" — is structurally unreachable: seeding happens inside the `Session` constructor, `_surface` is created lazily on first access, and the log reference is never reassigned afterward, so no constructed `SurfaceManager` ever observes a wholesale replacement. Sole caller: its own unit test. A rollback primitive protecting a scenario the implementation cannot produce. -2. **The `runLoop`, `Inbox`, and `InboxMessage` exports** (`packages/core/agent-loop/src/index.ts`). `runLoop` has no importer outside the package — the only callers are the package's own internals (the agent constructs its loop with it), so the public re-export has zero consumers; `Inbox`/`InboxMessage` likewise reach outside code only through the package's own inbox spec (switchable to the source module). The exports contradict the package's own docs — the inbox module doc says the public surface is `Agent.send()`/`Agent.steer()` — and the [architecture dependency rule](../../../architecture.md): nothing programs against `dsh-agent-loop`; a replacement loop is a different bundle built on `dsh-agent`, not a consumer of this package's internals. `ReactLoopAgent` stays exported (cross-package tests construct it by package name). -3. **`ToolExecutionResult.callId`** (`packages/core/tools/src/index.ts`; the input `ToolExecution.callId` stays). Zero consumers read it. A `tools/execute` wrapper may construct or replace a result, but the registry rejects any `callId` that differs from the immutable execution identity and rebuilds later outcomes from protected snapshots; `tools/post-execute` receives that same execution beside the result, and the observe-only `tools/result` notification receives both as immutable values. The loop independently correlates with its model call's `call.id`, while ACP correlates through the session event's `data.callId`. The result field is therefore a compulsory copy of information already present at every extension point, plus validation and regression tests whose only job is to prove the copy cannot disagree. +The production corpus is `packages/*/*/src`, example sources/config, and runtime scripts. Tests, package READMEs, and RFC prose are evidence of publication but not fixed callers. `cordis_inspect` makes `packages/cordis/tool-cordis/src/api-catalog.ts` model-visible, and `cordis_mount` can invoke injected services through guarded real-service proxies, so catalogued service methods and returned shapes are a genuine dynamic product surface. The table therefore distinguishes absence of a fixed repository caller from unreachability: rows touching catalogued vocabulary intentionally contract what model-written mounts can discover and call, while package-root implementation helpers are not reached through that service façade. Exact-symbol searches produce the following inventory: + +| Surface | Production evidence | Simplification | +| --- | --- | --- | +| `SurfaceManager.invalidate()` | Only its unit test calls it; seeding completes before the lazily-created manager exists and the session never replaces its log reference. | Delete it and its impossible wholesale-replacement contract. | +| `ToolExecutionResult.callId` | Every hook already receives the immutable `ToolExecution`; the loop and ACP correlate through the call/session event. No consumer reads the duplicate result field. | Remove the field, copy/mismatch guards, and tests that prove the duplicate cannot disagree. | +| `ReactLoopAgent` root export | Outside-package named imports are tests; production programs against `Agent` and creates/resumes through `ctx.agents`. | Return/interface-type `Agent` and make the concrete loop class package-internal; keep the deliberate synchronous config-only `AgentLoop.create()` path. | +| `workflow-workerthread` protocol/runtime/session re-exports and named `WorkerWorkflowEngine` | Every package-name consumer uses the default engine; the workflow RFC already defines the worker wire protocol as private. | Keep the default plugin class/config contract; drop the duplicate named class export and keep protocol modules source-private. | +| `code-runtime-worker` protocol/bootstrap re-exports | Outside-package production/e2e consumers use `WorkerCodeRuntime` and config, not `BootstrapPort`, `PatchableStream`, or worker message/boot types. | Keep the runtime class/config contract and make its wire/bootstrap vocabulary source-private. | +| ACP translation/presenter root exports | `agentOptions`, `streamSessionEventUpdate`, `todosToPlan`, `ToolPresenter`, `nullToolPresenter`, and `TerminalRendering` have only same-file or ACP-test consumers; the sole outside-package production consumer mounts the plugin namespace. | Keep `name`, `inject`, `Config`, `AcpConfig`, and `apply`; make translation/presentation helpers source-private and test them in-package. | +| `providerWording` and `completedTurnPrefix` root exports | Each has one same-package production caller; only the balanced-prefix helper has a same-package white-box test. | Make them source-private and test provider behavior. | +| `depthOf`, `SubagentDepthError`, `SENSITIVE_ENV_PATTERN`, `waitForExit`, and `exitsWithin` root exports | Production subagent backends consume the in-process runner and subprocess construction/disposal helpers, not these enforcement/test internals. | Keep depth/environment/exit behavior but make the helpers and error/regex source-private; test through spawn and disposal. | +| `PersistenceCoordinator.inits`, backend `inits` accessors, `seedCoversPrefix`, and `assertSerializable` | The accessors exist for white-box tests; `seedCoversPrefix` has no outside production importer; `assertSerializable` has no production caller and duplicates the coordinator append boundary's lossless snapshot. | Observe initialization through `session/flush`, make `seedCoversPrefix` source-private, and delete `assertSerializable`. Keep both backends, `SessionHeader`, and SQLite's version contract. | +| `LlmError.status` and replay status | Adapters/replay populate it, but production branches on stable error code/message and never reads raw status. | Remove the unread field and replay plumbing while preserving error classification. | +| `BlockAssembler.push()` return value | Both production callers ignore the returned completed block. | Return `void`; keep the deliberately public `blocks()`/`message()` contract. | +| `compactRegion`'s separate `session` argument | The fixed caller passes the same object already present as `agent.session`; the model-visible mount API can also call the method, but accepting two identities permits a mounted plugin to provide an incoherent pair. | Keep the manual-region seam while deliberately narrowing it to `agent.session` as the one source of truth. | +| `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | +| `BasicCompactService` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented RFC names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | +| `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | +| `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | +| Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | +| Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | + +### Grouped helper-export inventory + +- `dsh-llm-deepseek`: `httpErrorCode`, `serializeMessages`, `serializeRequest`, `DONE`, `parseSse`, `mapFinishReason`, `mapUsage`, and `translate`; `dsh-llm-pi-ai`: `buildModel`, `mapStopReason`, `mapUsage`, `toPiContext`, and `toStreamChunks`. +- `dsh-bash-local`: `DEFAULT_GRACE_MS`, `ENV_OVERRIDES`, `killGroup`, `OutputCollector`, and `runBash`; `dsh-bash-sandbox`: `shellQuote`, `classifyDenial`, and `classifyRunnerFailure`; `dsh-sandbox-local`: `bwrapProfileArgs`, `landlockProfileArgs`, and `seatbeltProfileArgs`. The public mutable test-injection fields and their types are outside this proposal. +- `dsh-fs-local`: `applyLiteralEdit`, `listDirectory`, `probe`, `readForEdit`, `readTextForDiff`, `readWholeText`, `resolveLocalTarget`, `restoreLineEndings`, `streamWholeText`, and `writeFileAtomic`. +- `dsh-web-fetch-local`: `classifyContentType`, `decoderForCharset`, `isSameOrigin`, `parseCharset`, and `validateFetchUrl`; `dsh-web-search-exa`: `mapExaResponse` and `mapExaResult`; `dsh-web-search-deepseek`: `citationSnippets` and `mapAnthropicResponse`; `dsh-web-search-perplexity`: `mapPerplexityResponse` and `mapPerplexityResult`. +- `dsh-tool-fs`: `READ_LIMIT`, `STREAM_MIN_SIZE`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `DIFF_CONTEXT`, `applyReadTool`, `parseReadArgs`, `applyWriteTool`, `formatWriteOutput`, `parseWriteArgs`, `applyEditTool`, `formatEditOutput`, `parseEditArgs`, `buildWindow`, `formatReadOutput`, `computeHunkDiffs`, and `diffsFromMeta`. +- `dsh-tool-web`: `WEB_SEARCH_MAX_RESULTS`, `applyWebSearchTool`, `formatSearchOutput`, `parseSearchArgs`, `presentSearchCall`, `applyWebFetchTool`, `formatFetchOutput`, `parseFetchArgs`, `presentFetchCall`, `renderBody`, and `htmlToMarkdown`; `dsh-timeout-policy`: `toolTimeoutResult`; `dsh-compact-basic`: `resolveConfig`; `dsh-tool-bash`: `renderResult`. ## Proposal -Delete the dead method, exports, duplicate result id, construction and validation branches, and tests that exist only for them. Keep `ToolExecution.callId` and result `additionalContext`. Update the owning tools reference and session-surface record with the changed public shapes. - -Sequencing: the surface-cache work (tool-pairing balance caching) neither uses nor touches `invalidate`, so that removal can land after or alongside it mechanically. The full execution pipeline carries the immutable execution object through pre-policy, guards, around-dispatch wrappers, post-policy, and final result observation; nothing needs the result to repeat its id. +Remove or demote every row as one bounded coordinated public-surface cleanup. Update package READMEs, JSDoc, generated API/event catalogs, type-equivalence records, exports maps where needed, and tests so they exercise the owning public seam instead of preserving test-only entry points. Do not collapse any capability seam, LLM adapter, persistence backend, or lifecycle quiescence contract. ## Alternatives considered -### Why not keep them? +**Keep test conveniences and self-contained results public.** Public helpers can make white-box tests convenient, self-contained result fields can look ergonomic, and future embedders might want the concrete loop or enumeration methods. Those benefits are hypothetical; today they make every implementation and document explain states that no shipped caller can observe. A real consumer can introduce the smallest contract it needs, with its ownership and failure semantics known. -A future consumer that swaps a session's log in place would want a reset primitive — it re-adds `invalidate` with itself. A replacement-loop author might want to reuse the inbox or the driver — the architecture already answers that a replacement loop is a different bundle. An isolated result-logging listener might want self-contained correlation on the result — the execution object is in scope at every listener, and a field that exists only to be ignored is worse than absent: it invites exactly the orphaned-pairing bug the loop comment warns about. +**Keep every catalogued member for model-written mounts.** The self-referential toolset is a real generic consumer route, not generated-doc noise. Its value comes from an accurate, composable service surface, however, not from preserving duplicate fields or incoherent argument pairs indefinitely; each catalogued contraction above removes a fact available elsewhere on the same execution, agent, or result and updates the API reference in the same change. ## Acceptance criteria -- `invalidate()` and the result `callId` appear only in this RFC; `runLoop`/`Inbox`/`InboxMessage` remain package-internal only — no re-export from the package index and no outside-package importer; the agent-loop README lists only the consumed public surface; the inbox spec imports the source module. -- The complete tool-pipeline contract tests pass with the shrunk result type; the around-wrapper mismatch test, mutation-guard id assertions, and proves-ignored loop test disappear with the duplicate field. +- Exact-symbol searches show no removed surface outside this RFC and any implemented-RFC amendments. +- Every surface listed in this RFC is absent or demoted as specified; deliberately retained extension/test contracts outside the inventory are unchanged. +- Tool execution, compaction, both LLM adapters, both persistence backends, workflow isolation, and agent creation/resume retain their shipped behavior. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks -All three are compile-visible removals with no runtime behavior change on any shipped path. +Most removals are compile-visible but runtime-neutral. The compaction argument cleanup deliberately forbids a session/context mismatch while retaining the manual-region seam. External pre-release embedders and existing model-written mounts may import fewer helpers, pass fewer arguments, or receive narrower result shapes; this is an intentional product-surface contraction, not merely generated-catalog cleanup. The repository is unreleased, so carrying unsupported surface is the larger foundation cost. diff --git a/docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md b/docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md new file mode 100644 index 0000000000..9a0d9d2cb7 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md @@ -0,0 +1,32 @@ +# RFC: Drop unconsumed skill provider events + +Status: proposed + +## Problem + +Two skill-registry notifications are produced but have no production listener. The generated producer/consumer matrix and exact event-name searches find only declarations, emit sites, tests, generated catalogs, and prose for `skill/provider-added` and `skill/provider-removed`. + +Skill discovery reads the current provider map on demand, provider registration synchronously clears completed catalogs, and the post-await revision check prevents stale discovery from entering the cache. No sibling plugin waits for a skill provider through these events, unlike the live `subagent/provider-added` consumer that tolerates concurrent sibling loading. + +`tools/change` and `system-prompt/change` are explicitly outside this proposal. Existing simplification decisions retain them as intentional observation points for live tool and prompt UIs, and self-referential mounted plugins already use `tools/change`. This proposal also leaves `subagent/provider-added`/`removed` unchanged because `tool-subagent` has a production lifecycle consumer. + +## Proposal + +Delete the two skill-provider declarations and every emit path, rollback-order branch, test, and generated catalog/matrix row that exists only for them. Remove the corresponding skill-registry README/JSDoc contract. Where tests used an event to observe cleanup, assert provider lookup or collected output instead. + +Amend the skill-system RFC and package documentation so provider registration is described as direct effect-owned state with cache invalidation, not as a lifecycle notification contract. + +## Alternatives considered + +**Keep skill-provider notifications for future plugins.** A third-party plugin could observe provider availability, but direct provider registration and on-demand lookup are the extension contract; no current consumer needs a push signal. If a future sibling-load race appears, it can introduce a notification with the identity and readiness semantics that consumer requires, as the subagent registry did. + +## Acceptance criteria + +- The generated event matrix contains no row for `skill/provider-added` or `skill/provider-removed`. +- Skill discovery, direct runtime registration, provider effect rollback/disposal, cache invalidation, and registry lookup cleanup behave unchanged; listener-triggered rollback disappears with the events. +- `tools/change`, `system-prompt/change`, and the real subagent provider lifecycle consumer remain documented and covered. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Risks + +This removes pre-release skill-provider observation points while retaining both ways third-party plugins contribute skills: direct runtime registration and provider registration. A future consumer that needs live provider availability must add a purpose-built notification rather than relying on these generic events. diff --git a/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md b/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md new file mode 100644 index 0000000000..1a8495426e --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md @@ -0,0 +1,30 @@ +# RFC: Prune unused web seam fields + +Status: proposed + +## Problem + +The web capability carries request/result/status values that every shipped implementation populates but no production consumer reads. `WebSearchResult.providerId` and `query` and `WebFetchResult.providerId` are result echoes; `tool-web` formats only content/sources/truncation or final URL/status/body/truncation, and no other runtime reads them. Search providers return `WebProviderStatus.reason`, but resolution checks only `available` and intentionally emits a generic unavailable diagnostic. + +`WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists. + +## Proposal + +Remove the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Shrink provider status to availability alone, preferably a boolean-returning method if that produces the clearest seam. Remove per-request fetch timeout, `maxTimeoutMs`, and their clamp/validation branches while retaining the provider's configurable default timeout and tool-level deadline. Replace `WebExecContext` with a direct optional `AbortSignal` parameter. + +Update all web implementations, the model-facing tool, package READMEs/JSDoc, type-equivalence records, and tests. Keep the interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and all safety limits. + +## Alternatives considered + +**Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object. + +## Acceptance criteria + +- Every retained web request/result/status field has a production reader or is required to execute the provider request. +- Tool-visible search/fetch output, provider fallback, abort behavior, configured timeout backstop, truncation, and citations remain covered. +- No `maxTimeoutMs`, request-timeout precedence branch, or one-field execution-context wrapper remains. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Risks + +Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound. diff --git a/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md new file mode 100644 index 0000000000..f335afafe1 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md @@ -0,0 +1,36 @@ +# RFC: Simplify session-log representation + +Status: proposed + +## Problem + +The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas. + +`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate. + +The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid. + +This proposal deliberately retains append and replacement `sourceEventSeqs`, crash-repair provenance, and all `SessionStartSource` variants: implemented RFCs give those fields an audit/interception role that zero current readers does not overturn. + +## Proposal + +Make `SurfaceManager.nodes` a `readonly number[]` of event sequences and remove the public `SurfaceNode` shape. Keep the internal replace-generation signal; update tool-pairing balance and compaction callers to use array values/indices for predecessor, successor, and replacement ranges, removing node links and the seq-to-node map. Replace post-anchor header deltas with canonical full changed-header snapshots and remove the delta codec/event/tests; initial and resume anchors remain full snapshots even when the folded header is unchanged. + +Amend the session-surface and reconstructable-request RFCs where they describe the removed encoding. Update event types/invariants, request logging/replay, persistence fixtures, generated catalogs, package docs, and snapshots. Replace the codec-only `fallback` reason with an explicit `change` reason for post-anchor full snapshots, distinguishing them from the retained `initial` and `resume` anchors. + +`SESSION_FORMAT_VERSION` is deliberately pinned at `0`, so an old v0 log containing `request/header-delta` would otherwise pass the version check and silently lose header changes after the delta fold is deleted. Seed/load validation must reject that legacy event fail-loud at the format boundary; no compatibility fold or migration is added. + +## Alternatives considered + +**Keep linked nodes and compact deltas for possible scale.** Links could help a future cursor API, and deltas can reduce logs when large tool schemas change by a small amount. No shipped cursor uses the links, while full snapshots trade disk size for substantially simpler correctness. If header volume proves material, compression or a measured canonical-delta scheme can be designed around real traces. + +## Acceptance criteria + +- `SurfaceManager.nodes` is one ordered seq array with no `SurfaceNode`, link fields, or seq-to-node map; incremental append processing and the internal replace-generation signal remain, while the separate public `invalidate()` deletion stays owned by the dead-surface RFC. +- Replaying full changed-header snapshots reconstructs exactly the same requests; no header-delta event/type/codec remains. +- A v0 seed or persisted log containing legacy `request/header-delta` is rejected before replay, with coverage for JSONL and SQLite load paths. +- New-shape v0 JSONL/SQLite replay, provenance, crash repair, compaction, snapshots, invariants, typecheck, coverage, doc-sync, build, and hygiene pass. + +## Risks + +Full headers increase log volume, and linear replacement lookup could be slower on very large surfaces. Replacements are already linear because the implementation calls `indexOf`; benchmarks should be added only if real traces show the simpler array is a bottleneck. Because the format version remains `0`, forgetting the explicit legacy-event rejection would be silent data corruption rather than a type error; the fail-loud load test is therefore part of the proposal, not optional cleanup. diff --git a/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md new file mode 100644 index 0000000000..7624629d41 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -0,0 +1,37 @@ +# RFC: Collapse workflows to the exercised foreground core + +Status: rejected — Workflow progress is an intentional observation surface; make it useful through a consumer instead of deleting it. + +## Problem + +The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. + +The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. + +The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. + +Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. + +`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. + +## Proposal + +Keep the exercised core: `agent(prompt, { schema, model })`, `parallel`, `pipeline`, `args`, concurrency/agent caps, cancellation, bounded disposal, structured results, worker isolation, and foreground tool collection. Remove all `workflow/*` events and their event-only info/outcome types; remove `phase()`, `log()`, agent `label`/`phase`, phase declarations, `whenToUse`, and their worker messages/host observers; collapse workflow metadata to the name the tool actually uses; remove event-only run ids/meta snapshots and the synthesized agent-end ledger. Shrink `WorkflowRun` to `result`, `cancel()`, and `dispose()`; the tool renders the request-owned name. Remove `WorkflowStartRequest.signal` and the worker host's input-signal listener/disarm state, retaining the caller-owned bridge from its abort signal to `run.cancel()`. Make `WorkflowError` one fatal error class without a boolean mode or `isFatalWorkflowError()` helper. + +Amend the implemented dynamic-workflow RFC and update the seam/tool/worker READMEs, tool schema, generated catalogs and package graph, worker type-equivalence records, unit tests, and workflow snapshot/header fixtures. Progress UI work, if commissioned, starts from a correlation contract that names the parent agent/session/tool call instead of reviving this protocol unchanged. + +## Alternatives considered + +**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. + +## Acceptance criteria + +- The workflow public seam contains only execution, cancellation, result, and disposal contracts with a production consumer. +- No workflow event, phase/log protocol message, run-id generator, progress-only metadata, host pairing ledger, or fatal-mode branch remains. +- The run handle has no id/meta echoes, and cancellation has one holder-owned channel after synchronous `start()` returns. +- Parallel/pipeline behavior, caps, cancellation quiescence, worker containment, structured output, and the model-facing workflow scenarios retain coverage. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Risks + +This is a compile-visible contraction of the workflow DSL, event taxonomy, handle, and start request. Existing workflow calls that supply descriptive metadata, and scripts that use `phase`, `log`, or labels, must shrink; programmatic callers bridge their own abort source to the returned handle; and a future observer must add a better-correlated seam. The execution semantics that make workflows useful do not change. diff --git a/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md new file mode 100644 index 0000000000..cafd50e8a5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md @@ -0,0 +1,27 @@ +# RFC: Prune unused skill registry surface + +Status: rejected — Direct runtime skill registration is an intentional extension path for third-party plugins. + +## Problem + +The skill service's embedded-runtime subsystem has zero production caller of `ctx.skills.register()`. It adds a reserved `runtime` provider name, a runtime map/rank/source, duplicate policy, a second revision in cache keys, normalization, disposers, and tests alongside the provider seam every shipped skill already uses. `SkillSummary.whenToUse` and candidate/definition `path` are parsed and copied but never read by a production consumer: the model catalog renders name/description, resource loading uses `resourceBase`, and providers own their locator. The deliberately open `metadata` extension point stays. + +## Proposal + +Remove `SkillService.register()`, `SkillRegistration`, the runtime pseudo-provider and reserved-name rules, runtime revisions/cache branches, and runtime-only source/rank normalization. Tests that need an embedded skill register a small real provider. Retain `providerRevision` as the in-flight discovery epoch, but key completed catalogs by cwd alone: every provider mutation synchronously clears the cache, and the post-await revision comparison already prevents inserting stale work. Remove `whenToUse`, `SkillCandidate.path`, and `SkillDefinition.path` from the skill contract and local-provider copies while retaining provider locator/root paths; retain `metadata`, `disableModelInvocation`, `source`, `provider`, `locator`, and `resourceBase` as either deliberate extension vocabulary or production-consumed fields. + +Amend the skill-system RFC, README, JSDoc, catalogs, and tests. Agent-scoped system-prompt sections, tool providers, and variables are explicitly outside this proposal: the [agent-scope contributor contract](../../implemented/architecture/2026-07-08-agent-scope-contexts.md) intentionally allows all three to be registered during `setup(agentCtx)` through the agent-owned context, so absence of a fixed in-repo scoped registration is not evidence of non-consumption. + +## Alternatives considered + +**Keep runtime skill registration for embedders.** It is a deliberate synchronous direct-definition convenience in the implemented skill RFC. A small provider wrapper can expose the same embedded data under effect-owned lifetime, but it must implement async `list()`/`get()`, carry provider identity, and accept provider duplicate semantics. The proposal chooses that one regular path over preserving a second ranking, validation, cache-invalidation, and lookup path. + +## Acceptance criteria + +- Skill collection has one provider-backed path, a cwd-only completed-cache key, and a revision epoch only for in-flight invalidation; retained skill fields have a production reader or a recorded deliberate extension contract. +- Agent-scoped prompt sections, variables, tool providers, tool guards, and structured-output commit behavior in native and Code Mode remain unchanged. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Risks + +This is a compile-visible contraction of the pre-release skill registry. External programmatic `list()`/`get()` consumers lose `whenToUse` routing hints and candidate/definition `path`; the shipped model catalog never renders them, and resource resolution keeps its explicit `resourceBase` plus provider-owned opaque locator, but those fields are not observationally identical. Skill-local frontmatter parsing must continue to preserve and validate the supported metadata schema, and external providers remain able to supply embedded, filesystem, remote, or other skill sources. diff --git a/docs/testing.md b/docs/testing.md index 62989a1096..f9264df78e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,7 +4,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning ## Tiers -- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, and concurrency races; review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). +- **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): 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)). Use `pnpm run test:snapshot:record` when the model transcript should change; use `pnpm run test:snapshot:refresh` when the committed transcript is still the right mock LLM input and replay goldens need keyless rewrite. Review the golden diff. 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)). diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index e789101e3b..51641d4655 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -48,6 +48,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit policy stays on `fs/*` events. Generic pre/post waterfalls host hook and approval policy, `ctx.approval` resolves asks before guards, and `tools/execute` hosts around-dispatch concerns such as timeouts. `tools/result` observes the immutable final outcome. Code Mode sends both `run_code` and its serialized sub-calls through this pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/eslint.config.mjs b/eslint.config.mjs index 2714f66ac3..785a82891f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,8 @@ import stylistic from '@stylistic/eslint-plugin' import tseslint from 'typescript-eslint' +// Strict type-aware correctness rules plus repository formatting. Tests/examples relax deliberate +// mock unsafety; vendored sources retain upstream style and receive only selected safety checks. export default tseslint.config( { ignores: [ @@ -24,7 +26,8 @@ export default tseslint.config( ], languageOptions: { parserOptions: { - // Share one project service to avoid per-package graphs and excessive memory. + // One project service resolves each file to its owning tsconfig and shares dependency + // graphs. Per-package programs duplicated path-mapped and Cordis closures, reaching ~5 GB. projectService: true, tsconfigRootDir: import.meta.dirname, }, diff --git a/examples/AGENTS.md b/examples/AGENTS.md index f286277dea..57274033bc 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,20 +1,22 @@ # AGENTS.md — Examples -Runnable harness compositions. **Examples are not workspaces:** their private package stubs are not built; `tsx` and the Cordis Loader resolve package names through the root `tsconfig.json` paths. +Runnable harness compositions. **Examples are not workspaces:** private package stubs are not built. App bins load each `cordis.yml` through `tsx`; package names resolve through root `tsconfig.json` paths, not `node_modules`. -Keep only wiring, demo-only fixtures, and e2e/snapshot scenarios here. Move reusable logic into `packages/`, where coverage and README requirements apply. App-package bins own bootstrapping; examples have no `start.ts`. +Keep wiring, demo fixtures, and e2e/snapshot scenarios here. Move reusable logic into `packages/`, with coverage and a README. App bins own bootstrapping; examples have no `start.ts`. -## Every example ships e2e smokes (keyless + with-key) +## E2E smokes -Each example has both smoke tiers: +Each example has both: -- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output plus clean exit. This catches Loader/export-shape failures that hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). +- **Keyless:** boot the real `cordis.yml` through the Loader, drive it, and assert output and clean exit. Catches Loader/export-shape failures hand-mounted tests miss ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **With-key:** send a live-model prompt and verify external state, not the model's claim. Self-skip without `DEEPSEEK_API_KEY`; see [testing.md](../docs/testing.md). -Mock-only examples need only the keyless tier; state the exception in the test. +Mock-only examples require only the keyless tier; state that exception in the test. -A keyless smoke launched from a temporary cwd sets `TSX_TSCONFIG_PATH` to the root tsconfig and passes `--expose-internals` when loading HMR. +Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `--expose-internals` when loading HMR. -Do not maintain a prose inventory of example tests here; the `tests/` trees and root scripts are authoritative. +Do not inventory example tests here; the `tests/` trees and root scripts are authoritative. + +In `cordis.yml`, comment only non-obvious wiring, load-order consequences, replay, security boundaries, and configuration scope. Do not narrate visible entries; use [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for example prose. See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design. diff --git a/examples/README.md b/examples/README.md index 3a890b940b..5a94c7bdf0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is now a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads ONE app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks the swappable backends (an LLM adapter, a bash executor), loads one app package, and may add optional product tools or demo-only mocks. The composition — the spine, the front-door cluster, and the boot glue — lives in the app packages ([`@deepseek-ai/dsh-stdio-agent`](../packages/ui/stdio-agent), [`@deepseek-ai/dsh-acp-agent`](../packages/ui/acp-agent)) and the [`@deepseek-ai/dsh-agent-core`](../packages/core/agent-core) bundle they share. There is no `start.ts`; the `demo:*` scripts invoke each app package's `bin`. ## echo-agent @@ -35,6 +35,6 @@ Run with: `pnpm run demo:acp` (needs `DEEPSEEK_API_KEY`); `pnpm run demo:code-mo ## sandbox-acp-agent -The coding agent with its bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local) + [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) — the one-entry executor swap the `ctx.bash` capability seam exists for), served over ACP with [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval) mounted — the first composition where the approval loop is LIVE: a sandbox denial escalated by the model becomes a `session/request_permission` prompt in the editor, and "Allow once" runs exactly that command under the wider mode. +The coding agent with its bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local) + [`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox), the one-entry swap supported by the `ctx.bash` capability seam), served over ACP with [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval). A model retry after a sandbox denial becomes a `session/request_permission` prompt, and “Allow once” grants only that command the wider mode. Run with: `pnpm run demo:sandbox-acp` (needs `DEEPSEEK_API_KEY`; bwrap, a Landlock-enforcing kernel, or macOS for confined runs). See [sandbox-acp-agent/README.md](sandbox-acp-agent/README.md). diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 8ee54b3078..09dbe796fd 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -1,9 +1,6 @@ -# Both-mode REPLAY overlay: the same patched tree as both-mode.cordis.yml -# (registry in `mode: both` + the worker code runtime) with the keyless model -# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving -# the recorded fixture). Patches do not compose across nested includes — -# an outer include's patch can only target entries in the file IT loads — so -# this file patches ./cordis.yml directly with the union of both overlays. +# Keyless both mode combines the runtime/registry patch with the DeepSeek-to-replay +# swap. Include patches cannot target entries behind a nested include, so this file +# applies both overlays directly to `cordis.yml`. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index 3dff66d60a..d92a66c250 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -1,11 +1,7 @@ -# Both-mode RECORD overlay: the live acp-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: both }` -# (every native tool definition stays on the wire AND run_code + the generated -# TypeScript SDK prompt section ride along) and the worker-thread code runtime joins the -# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file when the -# snapshot harness records the both-mode scenario; DSH_SNAPSHOT=replay swaps -# it for the sibling both-mode.cordis.snapshot.yml. A config patch REPLACES -# the entry's whole config, so the base entry's fields are restated verbatim. +# Both mode adds `ctx.codeRuntime` while keeping native tools on the wire and +# adding `run_code` plus its generated TypeScript SDK prompt. The app bin selects +# this overlay for snapshot recording and the sibling overlay for replay. A config +# patch replaces the whole app config, so unchanged base fields are restated below. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index d525afc5d6..14c36e4399 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -1,9 +1,6 @@ -# Code Mode REPLAY overlay: the same patched tree as code-mode.cordis.yml -# (registry in `mode: code` + the worker code runtime) with the keyless model -# swap from cordis.snapshot.yml (llm-deepseek disabled, llm-replay serving -# the recorded fixture). Patches do not compose across nested includes — -# an outer include's patch can only target entries in the file IT loads — so -# this file patches ./cordis.yml directly with the union of both overlays. +# Keyless Code Mode combines the runtime/registry patch with the DeepSeek-to-replay +# swap. Include patches cannot target entries behind a nested include, so this file +# applies both overlays directly to `cordis.yml`. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index 323c35b5b4..d7d60f5af9 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -1,12 +1,8 @@ -# Code Mode overlay: the live acp-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: code }` -# (the registry offers exactly one wire tool, run_code, plus the generated -# TypeScript SDK prompt section) and the worker-thread code runtime joins the -# tree as `ctx.codeRuntime`. The dsh-acp-agent bin boots this file for -# `pnpm run demo:code-mode acp` and when the snapshot harness records the -# code-mode scenarios; DSH_SNAPSHOT=replay swaps it for the sibling -# code-mode.cordis.snapshot.yml. A config patch REPLACES the entry's whole -# config, so the base entry's fields are restated verbatim. +# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, +# `run_code`, plus its generated TypeScript SDK prompt. The app bin selects this +# overlay for `demo:code-mode acp` and snapshot recording, and selects the sibling +# replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app +# config, so unchanged base fields are restated below. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index fd8a9dcad2..f69770b4dd 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,30 +1,17 @@ -# Snapshot-test REPLAY overlay: the SAME app tree as cordis.yml, derived from -# it by an include — the one difference is the model backend. A keyless replay -# run cannot boot the real adapter (llm-deepseek's apply() throws without -# DEEPSEEK_API_KEY), so the include patches the live tree at load time: the -# llm-deepseek entry is disabled by id, and the llm-replay entry (which serves -# a recorded session JSONL — no API key, no network) is inserted. Every other -# entry — the app, the bash executor, the fs/subagent/todo tools, both hook -# bridges, the system prompt — IS the live tree, so replay exercises exactly -# what ships and an app-shape change lands once, in cordis.yml. -# -# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. The replay -# fixture path comes from $DSH_SNAPSHOT_FILE (and an optional -# $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. stdout stays -# reserved for the ACP JSON-RPC protocol (the app package loads no stdout -# logger). Patches apply when the include loads the file — a one-shot replay -# boot, so the load-time-only patch semantics are exactly enough. +# Keyless replay includes the live `cordis.yml`, disables the key-requiring +# DeepSeek adapter, and inserts `llm-replay` to serve recorded JSONL without a key +# or network; every other app entry remains shared. +# With `DSH_SNAPSHOT=replay`, the app bin reads `DSH_SNAPSHOT_FILE` and optional +# `DSH_SNAPSHOT_OVERRIDE` from the harness. The one-shot patch applies at include +# load time, and stdout remains reserved for ACP JSON-RPC. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: - # The name is an assertion, not an override: the include skips the patch - # (warning if a logger exists) when the id points at a different plugin, - # so this can never disable the wrong entry. If cordis.yml ever RENAMES - # the id, the patch degrades to a skip — replay output stays correct - # (llm-replay still short-circuits the stream) but the stale patch and a - # futile keyless adapter entry linger until review catches them. + # `name` asserts the target: a mismatch skips the patch and warns only when + # a logger exists. A renamed id leaves a stale adapter entry, but replay still + # short-circuits through `llm-replay`. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index dc03ea6b03..7ee2385711 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -1,17 +1,8 @@ -# The acp-agent plugin tree: the ACP server. Also the snapshot RECORD config -# (the dsh-acp-agent bin selects it for DSH_SNAPSHOT=record): a real llm-deepseek -# run whose persisted log the snapshot harness harvests. The swappable DeepSeek -# adapter, local bash/filesystem executors, the ACP server app -# (@deepseek-ai/dsh-acp-agent), and the optional model-facing fs/subagent/todo -# tools loaded below. -# -# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved for -# the ACP JSON-RPC protocol (see packages/ui/acp). That guarantee is now a -# property of @deepseek-ai/dsh-acp-agent (it contains no logger entry), not a -# leaf convention: there is no logger here to get wrong. -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). +# ACP server and snapshot-record composition. With `DSH_SNAPSHOT=record`, the +# app bin runs the real DeepSeek adapter and the harness harvests its persisted log. +# `dsh-acp-agent` loads no stdout logger or HMR because stdout carries ACP JSON-RPC. +# It loads the gitignored root `.env` on stderr before reading `DEEPSEEK_API_KEY` +# and optional `DEEPSEEK_BASE_URL` here. # The DeepSeek adapter. - id: llm-deepseek @@ -23,8 +14,7 @@ - deepseek-v4-flash - deepseek-v4-pro -# Local bash executor for agent-core's tool-bash schema (one of several tool -# stacks in this tree: filesystem, subagent, and todo_write load below). +# Local executor for the app bundle's bash tool. - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -38,22 +28,16 @@ config: model: deepseek-v4-flash persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - # The persona: identity + behavior only, nothing about transports or - # tooling — tool guidance lives with each tool plugin (descriptions + - # prompt sections). {{model}} and {{cwd}} are prompt variables the agent - # loop resolves per session (every ACP session carries the client's cwd, - # so the persona can state the workspace). + # Keep the persona to identity and behavior; tool plugins own tool guidance. + # The loop resolves {{model}} and each ACP session's client-supplied {{cwd}}. persona: | You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. -# The subagent seam + both in-process backends + two model-facing tools, as leaf -# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh -# child) and fork (a child seeded with the parent's completed-turn prefix) are -# both reachable by the model: dsh-tool-subagent is loaded once per backend with -# a distinct toolName (subagent → spawn, subagent_fork → fork), so a multi-child -# scenario can exercise both transports. +# Expose fresh-child `spawn` and completed-prefix `fork` through separate tool +# names so multi-child scenarios exercise both transports. These leaves follow +# the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -80,10 +64,8 @@ toolName: subagent_fork -# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn -# subagent backend above, plus the model-facing `workflow` tool. The model -# writes a JavaScript orchestration script (meta + body); the engine runs it -# in its own worker thread and fans agent() calls out as spawn children. +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -91,23 +73,18 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' -# The model-facing todo_write tool: whole-list task tracking written to the -# session log (todo/write), surfaced to the ACP client as a `plan` update. +# `todo_write` replaces the logged whole list and surfaces an ACP `plan` update. - 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. +# Identical repeat calls trigger advisory context, never a block, at the default +# thresholds [3, 5, 8]. Only the repeat-tool-guard snapshot scenario reaches them. - 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 -# demo from the harness checkout with `pnpm --dir`. +# Policy loads before the model-facing filesystem tools so writes and edits require +# an observed file. Relative paths use the server launch cwd; the Zed setup launches +# this demo from the harness checkout with `pnpm --dir`. - id: fs-local name: '@deepseek-ai/dsh-fs-local' config: @@ -119,28 +96,19 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' -# The Claude Code hook bridge. `configPath` is PROCESS-LEVEL: it is read ONCE at -# load and the relative `./hooks.json` resolves against the ACP server's launch -# cwd, NOT each `session/new.cwd`. So a single `hooks.json` next to where the -# server starts applies to every session; a project-local, per-session hooks.json -# is NOT discovered (per-session config resolution is a TODO — see the bridge -# README). With no file present the parse fails-soft and the bridge registers -# nothing (a silent no-op). Hooks THEMSELVES run in the session cwd (the bridge -# passes it as the workdir); only WHERE the config is read from is process-level. -# stdout is the ACP JSON-RPC channel — the bridge's warnings go through ctx.logger -# (no exporter here), never to stdout. +# `configPath` is read once at load and resolves from the server launch cwd, not +# `session/new.cwd`; one `hooks.json` therefore applies to every session and a +# project-local file is not discovered. Missing config registers nothing. Hook +# commands still run in the session cwd. Warnings use `ctx.logger`, never stdout; +# see packages/hooks/hooks-claude/README.md for the deferred per-session design. - id: hooks-claude name: '@deepseek-ai/dsh-hooks-claude' config: configPath: ./hooks.json -# The Codex hook bridge, loaded alongside the Claude one. It reads its OWN config -# file (`./codex-hooks.json`, Codex's snake_case five-event dialect) — the two -# bridges cannot share one file, so each owns a distinct path. Same process-level -# read-once semantics and same fails-soft-when-absent contract: a launch cwd with -# no `codex-hooks.json` registers nothing (a silent no-op through ctx.logger, never -# stdout). The example ships both bridges so a scenario can exercise EITHER dialect -# end-to-end by seeding the matching file in its workspace/. +# Codex uses its own `codex-hooks.json` and snake_case five-event dialect; it +# cannot share Claude's file. It has the same process-level, read-once, missing-is-no-op, +# logger-only contract. Shipping both bridges lets a scenario seed and exercise either dialect. - id: hooks-codex name: '@deepseek-ai/dsh-hooks-codex' config: diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index a04742ad82..06fee424bd 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -26,12 +26,13 @@ import { * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. +// The child runs from a temp cwd, so its bin and config path are absolute. const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Resolve tsx absolutely because the subprocess runs outside the repo. const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Absolute path to the repo-root tsconfig. +// The root tsconfig supplies unbuilt workspace `paths`; making it explicit +// avoids accidental resolution through stale built output. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) interface Spawned { diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 2d63b4b346..fe8c09f04b 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -75,12 +75,15 @@ const SCENARIOS: Scenario[] = [ // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, - // Hook matrix — one scenario per hook point × its headline Decision outcome, across BOTH - // bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in workspace/). + // Prompt-submit blocks are authored keylessly: they persist a rejected turn + // and hook events without starting a model step, so their logs still compare. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false }, // The mid-turn seams fire during a real model turn, so each is recorded with its hook active // (the model's reaction to a deny/block/force-continue is part of the captured transcript). + // SessionStart/SubagentStart are excluded because detached injection races log + // order; SubagentStop writes no transcript, so a golden could not prove it ran. + // Unit tests cover those points; the hook-snapshot-matrix RFC owns the rationale. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true }, { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true }, @@ -97,7 +100,7 @@ const SCENARIOS: Scenario[] = [ { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true }, // Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the // tools:sdk section rides in the prompt, and the program's tool calls land as - // tool/code-dispatch events. + // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, ] diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index df891c9124..b14e3fb8cd 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -17,8 +17,10 @@ import { } from '@agentclientprotocol/sdk' /** - * With-key e2e: the Claude Code hook bridge running against the real acp-agent subprocess and - * the real model. + * With-key e2e for the Claude hook bridge. The process-level `./hooks.json` is + * resolved from a temporary launch cwd and blocks all PreToolUse calls; a real + * model is asked to write there, and absence of the file proves interception. + * The test owns and disposes the ACP subprocess. */ const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) @@ -76,7 +78,8 @@ afterEach(async () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { it('denies every bash command, so the requested file is never written (verified on disk)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-hooks-e2e-')) - // A PreToolUse hook that blocks every tool (exit 2, no matcher = match-all). + // `configPath` is process-relative, so placing the match-all hook in the + // launch cwd selects it; hook commands themselves run in the session cwd. await writeFile(join(workdir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index ac4ce03570..8b4d22b917 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -1,13 +1,8 @@ -# Code Mode overlay: the live coding-agent tree (./cordis.yml) with two -# load-time patches — the app entry's config gains `tools: { mode: code }` -# (the registry offers exactly one wire tool, run_code, plus the generated -# TypeScript SDK prompt section declaring bash/read/write/edit/subagent/ -# todo_write) and the worker-thread code runtime joins the tree as -# `ctx.codeRuntime`. The dsh-stdio-agent bin boots this file for -# `pnpm run demo:code-mode` (the acp-agent example carries the same-shaped -# overlay for the `acp` UI). A config patch REPLACES the entry's whole -# config, so the base entry's fields are restated verbatim; only `tools`, -# the welcome, and the persona's second paragraph are Code Mode deltas. +# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, +# `run_code`, plus a generated SDK for bash/read/write/edit/subagent/todo_write. +# `demo:code-mode` selects this overlay; the ACP example has the same UI-specific +# shape. A config patch replaces the whole app config, so unchanged base fields +# are restated; only `tools`, `welcome`, and the persona's second paragraph differ. - id: base name: '@cordisjs/plugin-include' config: diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index cf2e267e06..5581a910b1 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,15 +1,8 @@ -# The coding-agent plugin tree: the REPL agent demo. The two swappable -# backends — the DeepSeek adapter and the local bash executor — plus `hmr` for -# the dev/demo reload loop, then the stdio chat app (@deepseek-ai/dsh-stdio- -# agent), which bundles the whole agent-core spine (timer, llm, sessions, -# system-prompt, tools, agents, invariants, tool-bash, agent-loop), the console -# logger, JSONL persistence, the readline UI, and a pre-created `main` agent. -# -# `hmr` is a leaf entry (not baked into dsh-stdio-agent): it is a Loader-only -# dev plugin that needs `--expose-internals` — the `demo:repl` script passes -# it. Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) in the -# environment — the dsh-stdio-agent bin loads the gitignored repo-root .env -# first. cordis.yml reads them via the `!!js` tag. +# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-agent` +# supplies the agent-core spine, logging, JSONL persistence, readline UI, and `main` agent. +# HMR remains a leaf because it requires Loader internals; `demo:repl` passes +# `--expose-internals`. The app bin loads the gitignored root `.env`; this file +# reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. # Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr @@ -28,15 +21,13 @@ - deepseek-v4-pro - deepseek-v4-flash -# Local bash executor for agent-core's tool-bash schema (one of several tool -# stacks in this tree: filesystem, subagent, and todo_write load below). +# Local executor for the app bundle's bash tool. - id: bash name: '@deepseek-ai/dsh-bash-local' config: timeoutMs: 60000 -# The stdio chat app: the whole spine + front-door cluster, configured for a -# REPL agent demo driving a pre-created `main` agent. +# The app bundle pre-creates the REPL's `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: @@ -46,20 +37,16 @@ resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' welcome: 'agent REPL ready. Give it a coding task.' - # The persona: identity + behavior only, nothing about transports or - # tooling — tool guidance lives with each tool plugin (descriptions + - # prompt sections). {{model}} is the prompt variable the agent loop - # resolves from this agent's configured model. + # Keep the persona to identity and behavior; tool plugins own tool guidance. + # The loop resolves {{model}} from this agent's configuration. persona: | You are coding-agent, a coding assistant powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. -# Automatic context compaction: when the derived history approaches the model's -# context window, summarize an older range into a checkpoint so a long-running -# or tool-heavy session keeps fitting. A leaf entry (needs ctx.llm + the -# agent-loop's `agent/pre-step` seam from the app above). +# Summarize an older range when derived history approaches the context window. +# This leaf consumes `ctx.llm` and the app's `agent/pre-step` seam. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: @@ -70,13 +57,9 @@ maxTokens: 8192 compactionRetries: 1 -# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf -# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh -# child) and fork (a child seeded with the parent's completed-turn prefix) are -# independent backends over the shared dsh-subagent-inprocess driver. Exposing -# both transports is pure config: load each backend, then load dsh-tool-subagent -# once per backend with a distinct toolName (the tool registry rejects a -# duplicate name) — no code change. +# Expose fresh-child `spawn` and completed-prefix `fork` through independent +# in-process backends. Each tool instance needs a distinct `toolName`; the registry +# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. - id: subagent name: '@deepseek-ai/dsh-subagent' @@ -103,10 +86,8 @@ toolName: subagent_fork -# Dynamic workflows: the worker-thread engine (ctx.workflows) over the spawn -# subagent backend above, plus the model-facing `workflow` tool. The model -# writes a JavaScript orchestration script (meta + body); the engine runs it -# in its own worker thread and fans agent() calls out as spawn children. +# The worker-thread workflow engine fans a model-written JavaScript script's +# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: @@ -114,14 +95,12 @@ - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' -# The model-facing todo_write tool: whole-list task tracking written to the -# session log (todo/write), rendered as a stdio checklist / ACP plan. +# `todo_write` replaces the logged whole list and renders as a stdio checklist or ACP plan. - id: tool-todo name: '@deepseek-ai/dsh-tool-todo' -# Filesystem capability stack: local provider, read-before-write/edit policy -# gate, then the model-facing read/write/edit tools. stdio-agent is a single -# session, so relative paths resolve from the process cwd (the workspace). +# Policy loads before the model-facing filesystem tools so writes and edits require +# an observed file. This single-session app resolves relative paths from the process cwd. - id: fs-local name: '@deepseek-ai/dsh-fs-local' config: diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts index 2edcd3dcd2..59da071c39 100644 --- a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts @@ -10,7 +10,8 @@ import { afterEach, describe, expect, it } from 'vitest' * `@deepseek-ai/dsh-stdio-agent` bin against `code-mode.cordis.yml` (the cordis Loader, * `unwrapExports`, the include patches over ./cordis.yml, the worker-thread code runtime, and * the registry in `mode: code`), then close stdin with no prompt and assert the Code Mode - * banner + a clean exit. + * banner + a clean exit. A dummy key satisfies adapter boot, but no prompt means + * no model call; the with-key proof lives in `code-mode.e2e.ts`. */ const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) @@ -20,7 +21,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. const PROCESS_TIMEOUT_MS = 30_000 // Leave enough room for the process-owned timeout to report captured output // before Vitest aborts the test itself. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 221d61b3da..3f6e109e3f 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -16,8 +16,9 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' /** - * With-key Code Mode proof: a real model composes tool calls, writes a file, and - * returns curated output while the log records `run_code` and its sub-dispatches. + * With-key Code Mode proof: a real model receives only `run_code`, composes two + * sub-calls, writes a file, and returns curated output while the log records + * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 9a850644a0..932209115f 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -6,8 +6,12 @@ import type { Context } from 'cordis' import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' -/** Key-gated smoke for mid-session compaction and continued agent progress. */ -// FIXME(compaction-snapshot): replay cannot serve the unlogged summarization model call. +/** + * Key-gated smoke for mid-session compaction. It verifies the compact event + * pair, replacement of older surface nodes, and a final answer after compaction. + */ +// FIXME(compaction-snapshot): this is the only full compaction coverage because +// replay cannot serve the summarizer's unlogged model call. let workdir: string | undefined let ctx: Context | undefined diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index d8721540a8..b2e0d92eb0 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -10,10 +10,11 @@ import { afterEach, describe, expect, it } from 'vitest' * `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the cordis Loader, * `unwrapExports`, the full plugin tree incl. the `@deepseek-ai/dsh-agent-core` bundle and the * app's in-package readline UI module), then close stdin with no prompt and assert the ready - * banner + a clean exit. + * banner + a clean exit. A dummy key satisfies adapter boot, but no prompt means + * no network call; with-key suites own the product behavior. */ -// The dsh-stdio-agent bin (the demo:repl entry) and this example's cordis.yml. +// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) @@ -21,7 +22,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig (root is four levels up). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. const PROCESS_TIMEOUT_MS = 30_000 // Leave enough room for the process-owned timeout to report captured output // before Vitest aborts the test itself. diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 953533d35b..1ddf8b1d3b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -30,4 +30,4 @@ Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the gener ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner + clean EOF exit (the export-shape / real-load-path guard, now across the package-name resolution). `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener (asserting the tagged console line actually fires — the world, not the agent's claim), builds itself a `reverse_text` tool and uses it, and composes two mounts via provide/inject. The tool logic itself is unit-tested in [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) under the per-file 100% coverage gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 65d5e6eb36..2634233cec 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,17 +1,11 @@ -# The cordis-agent plugin tree: the SELF-REFERENTIAL harness demo. Same spine -# as coding-agent (DeepSeek V4 + local bash on @deepseek-ai/dsh-stdio-agent), -# plus @deepseek-ai/dsh-tool-cordis, which gives the model three tools over the -# live cordis runtime it is running inside: cordis_inspect (services / plugin -# tree / tools / dynamic mounts / api / events), cordis_mount (evaluate -# model-written code in a vm sandbox and mount the returned plugin under the -# `cordis-dynamic` group), and cordis_unmount (dispose one mount by id). -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-stdio-agent bin loads the gitignored repo-root .env first. -# -# Trust stance (docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md): -# the mounted code gets the REAL ctx — the -# vm sandbox only prevents accidental global pollution. Load the toolset as -# deliberately as you would grant a bash tool. +# Self-referential stdio demo: the coding spine plus tools to inspect the live +# service/plugin/tool/mount/API/event state, mount a model-written plugin under +# `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored +# root `.env` before reading the required DeepSeek key and optional base URL. +# Trust stance: the vm and context façade limit accidental global/framework +# access but are not a security boundary; mounted code can reach live capabilities +# such as `ctx.bash`. Grant this toolset like bash access. See +# ../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. # Hot-module reload for the dev/demo loop (needs `node --expose-internals`). - id: hmr @@ -53,8 +47,7 @@ - id: web-fetch-local name: '@deepseek-ai/dsh-web-fetch-local' -# The stdio chat app: the whole spine + front-door cluster, configured for the -# self-referential demo driving a pre-created `main` agent. +# The app bundle pre-creates the self-referential demo's `main` agent. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 2bca2eb66b..32eb90c1ac 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -79,7 +79,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif await waitForIdle(ctx, agent) // World checks: the tool exists in the registry, was invoked as a real tool call, and its - // RESULT (the self-made execute actually running) is the reversed string. + // RESULT (the self-made execute actually running) is the reversed string. Model prose is only + // self-report and is deliberately not asserted. expect(ctx.tools.get('reverse_text')).toBeDefined() const events = [...agent.session.events] const calls = events.filter(event => event.type === 'tool/call') diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index ba56cac324..24b7fc42cd 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -11,10 +11,11 @@ import { afterEach, describe, expect, it } from 'vitest' * `unwrapExports`, the full plugin tree INCLUDING the `@deepseek-ai/dsh-tool-cordis` package * resolved by name (whose `inject` would crash a collapsed export shape at load, see * docs/postmortem/0001) — then close stdin with no prompt and assert the ready banner + a - * clean exit. + * clean exit. A dummy key satisfies adapter boot, but no prompt means no network + * call; `cordis-tools.e2e.ts` owns the with-key product proof. */ -// The dsh-stdio-agent bin (the demo:cordis entry) and this example's cordis.yml. +// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) @@ -22,7 +23,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // `paths` map; tsx searches UP from cwd, and we spawn from a temp dir outside // the repo, so point it at the repo tsconfig (root is three levels up). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. const PROCESS_TIMEOUT_MS = 30_000 // Leave enough room for the process-owned timeout to report captured output // before Vitest aborts the test itself. diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index b66c5e8163..a476927a89 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -1,9 +1,5 @@ -# The echo-agent plugin tree: the stdio chat app with its LLM backend swapped to -# the local `mock-echo` mock and the local `echo` tool added. The clean -# demonstration of "swap the backend, keep the app" — every service the agent -# needs lives in @deepseek-ai/dsh-stdio-agent (which bundles @deepseek-ai/dsh- -# agent-core); this leaf only picks the backends, `hmr`, and the app config. -# +# Stdio agent with the network-free `mock-echo` adapter and example-local `echo` +# tool. The app bundle supplies the spine; this leaf selects backends, HMR, and app config. # No API key: the `mock-echo` adapter never touches the network. # Hot-module reload for the dev/demo loop (a leaf entry, not baked into @@ -13,8 +9,7 @@ config: root: ['.'] -# The mock model (registers the `mock-echo` adapter) and the demo `echo` tool — -# example-local teaching plugins, resolved relative to THIS file's directory. +# Example-local model and tool plugins resolve relative to this file. - id: mock-llm name: './src/mock-llm.ts' @@ -27,8 +22,7 @@ - id: bash name: '@deepseek-ai/dsh-bash-local' -# The stdio chat app: console logger + the agent-core spine (pre-creating the -# `main` agent on the mock model) + JSONL persistence + the readline UI. +# The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-agent' config: diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 73d39c661a..6f15f1bff1 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -9,17 +9,19 @@ import { afterEach, describe, expect, it } from 'vitest' * Keyless Loader-path smoke for examples/echo-agent: boot the real example through the * `@deepseek-ai/dsh-stdio-agent` bin against this example's `cordis.yml` (the cordis Loader, * `unwrapExports`, the whole plugin tree), pipe a script of stdin lines, and assert the - * rendered stdout. + * rendered stdout. The mock adapter is network-free, making this the complete + * smoke; inputs cover both the echo-tool round trip and direct-reply branch. */ -// The dsh-stdio-agent bin (the demo:echo entry) and this example's cordis.yml. +// The temp-cwd child needs absolute bin and config paths. const binScript = fileURLToPath(new URL('../../../packages/ui/stdio-agent/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Dev/test run UNBUILT: `@deepseek-ai/dsh-*` imports resolve through the root tsconfig `paths` -// map, which tsx finds by searching UP from cwd. +// The temp cwd is outside the repo, so point tsx at the root config that resolves +// unbuilt workspace packages through `paths`. const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The real-API workflow runs up to 14 e2e files at once. +// Under parallel e2e load, cold tsx/Loader startup can exceed a tight deadline; +// 30s still detects a wedged child. const PROCESS_TIMEOUT_MS = 30_000 // Leave enough room for the process-owned timeout to report captured output // before Vitest aborts the test itself. diff --git a/examples/sandbox-acp-agent/README.md b/examples/sandbox-acp-agent/README.md index dead7d419c..10820a0a8c 100644 --- a/examples/sandbox-acp-agent/README.md +++ b/examples/sandbox-acp-agent/README.md @@ -1,6 +1,6 @@ # sandbox-acp-agent -The coding agent with its bash executor swapped for the sandbox stack ([`@deepseek-ai/dsh-sandbox-local`](../../packages/sandbox/sandbox-local/) + [`@deepseek-ai/dsh-bash-sandbox`](../../packages/bash/bash-sandbox/) — the one-entry executor swap the `ctx.bash` capability seam exists for), served over the **Agent Client Protocol**, plus [`@deepseek-ai/dsh-user-approval`](../../packages/ui/user-approval/) — which makes this the first composition where the approval loop is LIVE end to end: bash runs under `read-only`, a denial comes back as the structured marker, the model retries once with `sandbox_permissions` + `justification`, the ACP bridge's answerer turns that ask into a `session/request_permission` prompt in your editor, and "Allow once" runs exactly that command under the wider mode ([sandbox RFC § Escalation](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). +An ACP coding agent composed with [`dsh-sandbox-local`](../../packages/sandbox/sandbox-local/), [`dsh-bash-sandbox`](../../packages/bash/bash-sandbox/), and [`dsh-user-approval`](../../packages/ui/user-approval/). Bash defaults to `read-only`; after a denial, the model may retry once with `sandbox_permissions` and `justification`. The ACP bridge presents that call as `session/request_permission`, and “Allow once” grants only that command the wider mode. See the [sandbox escalation contract](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md#escalation-one-approved-wider-retry-after-a-denial). ```sh pnpm run demo:sandbox-acp # needs DEEPSEEK_API_KEY; drive it from Zed or any ACP client @@ -13,4 +13,4 @@ Zed setup is the same as [acp-agent](../acp-agent/README.md) with this example's - **The write boundary is config-fixed**: an escalated `workspace-write` run may write under the launch directory (`workspaceRoot: process.cwd()`) plus the platform temp area — a per-session root is config-phase future work in the [sandbox RFC](../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). - **No usable runner fails closed per command** (structured `SANDBOX_UNAVAILABLE`), and the filesystem tools stay unloaded for the same reason as `sandbox-agent`: they would bypass the bash sandbox. -`tests/escalation.e2e.ts` boots the real composition keylessly and exercises config-option advertisement, updates, and validation; with a key and usable runner it also world-verifies an allowed escalation. `tests/acp.snapshot.ts` pins config exchange, mode switching, and allowed and rejected approval branches through the shared snapshot kit. Replay executes recorded bash calls on the host runner, so Linux needs bubblewrap or Landlock while macOS uses Seatbelt. Fixtures avoid real denial stderr because that dialect is platform-specific. +`tests/escalation.e2e.ts` boots the real composition keylessly and exercises config-option advertisement, updates, and validation; with a key and usable runner it also world-verifies an allowed escalation. `tests/acp.snapshot.ts` pins config exchange, mode switching, and allowed and rejected approval branches through the [shared snapshot kit](../../packages/support/acp-snapshot/). Replay executes recorded bash calls on the host runner, so Linux needs bubblewrap or Landlock while macOS uses Seatbelt. Fixtures avoid real denial stderr because that dialect is platform-specific. diff --git a/examples/sandbox-acp-agent/cordis.snapshot.yml b/examples/sandbox-acp-agent/cordis.snapshot.yml index b5f8ac73ed..4173fc230f 100644 --- a/examples/sandbox-acp-agent/cordis.snapshot.yml +++ b/examples/sandbox-acp-agent/cordis.snapshot.yml @@ -1,27 +1,15 @@ -# Snapshot-test REPLAY overlay for the sandboxed composition: the SAME app -# tree as cordis.yml, derived from it by an include — the one difference is -# the model backend. A keyless replay run cannot boot the real adapter -# (llm-deepseek's apply() throws without DEEPSEEK_API_KEY), so the include -# patches the live tree at load time: the llm-deepseek entry is disabled by -# id, and the llm-replay entry (which serves a recorded session JSONL — no -# API key, no network) is inserted. Every other entry — the sandbox provider, -# the confined bash executor, the approval seam, the app — IS the live tree. -# The sandbox provider probes for a platform runner per EXECUTION, not at -# boot, so a protocol-only scenario (session config options) replays on hosts -# with no runner at all. -# -# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay (the -# sibling-swap of whatever config path it was handed). The replay fixture -# path comes from $DSH_SNAPSHOT_FILE, set by the snapshot harness. stdout -# stays reserved for the ACP JSON-RPC protocol. +# Keyless replay includes the live sandbox composition, disables the DeepSeek +# adapter, and inserts `llm-replay` to serve `DSH_SNAPSHOT_FILE` without a key or +# network. The sandbox, bash, approval, and app entries remain shared. Because +# runner probing happens per execution, protocol-only scenarios work without a +# platform runner. The app bin selects this sibling for `DSH_SNAPSHOT=replay`; +# stdout remains reserved for ACP JSON-RPC. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: - # The name is an assertion, not an override: the include skips the patch - # (warning) when the id points at a different plugin, so this can never - # disable the wrong entry. + # `name` asserts the target; a mismatch skips and warns instead of disabling another entry. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/examples/sandbox-acp-agent/cordis.yml b/examples/sandbox-acp-agent/cordis.yml index d02253342e..f7e8aca24e 100644 --- a/examples/sandbox-acp-agent/cordis.yml +++ b/examples/sandbox-acp-agent/cordis.yml @@ -1,16 +1,9 @@ -# The sandbox-acp-agent plugin tree: the sandboxed coding agent served over the -# Agent Client Protocol, with the approval seam composed — the first LIVE -# approval composition. A sandbox denial escalated by the model -# (sandbox_permissions + justification) reaches the EDITOR as a -# session/request_permission prompt through the ACP bridge's answerer, and an -# "Allow once" runs exactly that command under the wider mode. -# -# CRITICAL: this tree loads NO stdout logger and NO hmr — stdout is reserved -# for the ACP JSON-RPC protocol (a property of @deepseek-ai/dsh-acp-agent, -# same as examples/acp-agent). -# -# Requires DEEPSEEK_API_KEY (and optionally DEEPSEEK_BASE_URL) — the -# dsh-acp-agent bin loads the gitignored repo-root .env first (on STDERR only). +# ACP coding agent with sandbox and approval composed. A retry carrying +# `sandbox_permissions` and `justification` becomes `session/request_permission`; +# “Allow once” grants only that command the wider mode. +# `dsh-acp-agent` loads no stdout logger or HMR because stdout carries ACP JSON-RPC. +# It loads the gitignored root `.env` on stderr before reading `DEEPSEEK_API_KEY` +# and optional `DEEPSEEK_BASE_URL` here. # The DeepSeek adapter. - id: llm-deepseek @@ -21,13 +14,11 @@ models: - deepseek-v4-flash -# The sandbox stack: the platform-runner provider (bwrap → per-platform -# Landlock launcher → Seatbelt, functionally probed), then the confined bash executor. -# read-only is the fail-safe default; the write boundary for an escalated -# workspace-write run is workspaceRoot + the platform's temp area. NOTE: the -# workspace root is CONFIG-FIXED for the executor's lifetime (the launch dir -# here), while each ACP session has its own cwd — a per-session root is config- -# phase future work in the sandbox RFC. +# The provider probes bwrap, Landlock, then Seatbelt for each run. `read-only` +# is the fail-safe default; `workspace-write` permits `workspaceRoot` and the +# platform temp area. This root is fixed for the executor lifetime even though +# ACP sessions have distinct cwd values; per-session roots remain deferred in +# ../../docs/rfc/implemented/feature/2026-07-06-sandbox.md. - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' - id: bash @@ -36,16 +27,12 @@ mode: read-only workspaceRoot: !!js process.cwd() -# The approval seam (ctx.approval — mechanism only, no config): with it -# mounted, the bash tool's escalation gate has a channel, and the ACP bridge -# inside dsh-acp-agent answers for the sessions it owns by prompting the -# editor. Without an editor attached nothing can answer, and every ask fails -# closed. +# `ctx.approval` supplies the escalation channel; the ACP bridge answers for its +# sessions by prompting the editor. Without an attached editor, every ask fails closed. - id: approval name: '@deepseek-ai/dsh-user-approval' -# The ACP server app: the agent-core spine + JSONL persistence + the ACP -# bridge (whose approval answerer completes the loop). +# The app bundles the agent spine, JSONL persistence, ACP bridge, and approval answerer. - id: acp-agent name: '@deepseek-ai/dsh-acp-agent' config: diff --git a/examples/sandbox-acp-agent/tests/acp.snapshot.ts b/examples/sandbox-acp-agent/tests/acp.snapshot.ts index bcf28f105a..ec05e23b65 100644 --- a/examples/sandbox-acp-agent/tests/acp.snapshot.ts +++ b/examples/sandbox-acp-agent/tests/acp.snapshot.ts @@ -3,20 +3,25 @@ import { fileURLToPath } from 'node:url' import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot' /** - * Snapshot suite for the sandboxed composition (`../cordis.yml`, swapped to the sibling - * `cordis.snapshot.yml` replay overlay by the bin under `DSH_SNAPSHOT=replay`). + * Snapshot suite for the sandboxed composition. Replay swaps only the model; + * bash still runs under the host's Seatbelt/bwrap backend, so fixtures use + * portable `cat`/`printf` commands. Real denial stderr is deliberately absent + * because its wording varies by backend and platform; unit and kernel e2e tests + * own that path, while escalation fixtures start from a user-stated denial. */ const SCENARIOS: Scenario[] = [ // Protocol-only (keyless, authored): the session config-option surface this composition adds // — both advertised selects on session/new, the complete refreshed state every // session/set_config_option answers with, and both rejection shapes — as committed wire - // bytes. + // bytes. It runs no bash and therefore works without a sandbox runner. { name: 'config-options', hasModelTurn: false, recorded: false }, // The runtime mode-switching arc, and NECESSARILY the pinned-header scenario: an // approval-policy switch rewrites its prompt section, and the resulting request/header-delta - // is legal only in the pinning scenario (the factory's uniformity guard). + // is legal only in the pinning scenario. The pin includes that delta and + // notice; the sandbox switch stays prompt-silent and is proven by a confined write. { name: 'mode-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1 }, - // Pin both approval branches under the default read-only/ask policy. + // Under default read-only/ask, approval executes a confined retry; rejection + // executes nothing and returns deterministic text. { name: 'escalation-approved', hasModelTurn: true, recorded: true }, { name: 'escalation-rejected', hasModelTurn: true, recorded: true }, ] diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index 8317e44317..b8bf521e67 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -17,7 +17,10 @@ import { } from '@agentclientprotocol/sdk' /** - * examples/sandbox-acp-agent end to end. + * Sandbox ACP end to end. The keyless leg boots the real composition through + * initialize and session/new without a model or runner. With a key and runner, + * a scripted client grants a read-only denial's escalation once and the retried + * write must land on disk; unavailable prerequisites self-skip. */ const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) @@ -27,8 +30,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// A usable confining runner, probed the same way the executor suites do: bwrap on Linux, -// Seatbelt's sandbox-exec on macOS. +// Without a usable bwrap/Seatbelt runner, the strict attempt fails closed with +// SANDBOX_UNAVAILABLE instead of producing the denial this flow requires. const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], { timeout: 5_000, stdio: 'ignore', diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 0eb9fdaa42..693cb3b45a 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,6 +1,8 @@ /** - * `LocalBashExecutor`: the local-subprocess implementation of the `@deepseek-ai/dsh-bash` - * executor seam. + * Local-subprocess implementation of the bash seam. Each call runs in its own + * process group, background tasks are tracked, and disposal kills and awaits + * them. Execution policy belongs in `tools/pre-execute` or a sandboxing + * executor, not this local process layer. * @module @deepseek-ai/dsh-bash-local */ diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 099f1bf1b4..8ed8b7699c 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -1,6 +1,7 @@ /** - * Process plumbing for the local bash executor: spawn, output collection with tail-keep + - * spill-to-disk truncation, and process-group kill with SIGTERM→SIGKILL escalation. + * Process plumbing for the local bash executor: detached process-group spawn, + * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer + * reacts to an abort signal; the executor owns deadlines and classifies causes. * @module dsh-bash-local/run */ @@ -33,8 +34,8 @@ export const ENV_OVERRIDES = { export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** - * `process.env` minus credential-shaped vars, plus the model-friendly overrides, plus any - * caller-supplied `extra` entries. + * Build a child environment by scrubbing credential-shaped ambient variables, + * applying model-friendly overrides, then merging trusted caller entries last. * * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides. * @returns the environment to hand to `spawn` for the child process. @@ -236,8 +237,8 @@ export class OutputCollector { try { closeSync(this.spillFd) } catch { - // close can surface delayed writeback failures (for example EIO/ENOSPC) after writeSync - // appeared to succeed. + // A delayed writeback failure makes the spill unreliable; keep finalize + // total but stop advertising that file. this.spillFile = undefined } this.spillFd = undefined @@ -247,9 +248,9 @@ export class OutputCollector { } /** - * Send `sig` to the process GROUP led by `pid` (requires the child to have been spawned with - * `detached: true`). - * + * Send `sig` to a detached process group. Never throws: delivery races process + * exit and may run in a timer callback, so failures are contained and a + * non-positive pid is a no-op. * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. * @param sig - the signal to deliver to the whole group. */ diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 450ad7a81f..8f24b4b017 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -337,7 +337,7 @@ describe('LocalBashExecutor background tasks', () => { }) }) -describe('review fixes: lifecycle hardening', () => { +describe('executor cancellation, callback, and disposal contracts', () => { it('start honors a pre-aborted or later-aborted AbortSignal', async () => { const { bash } = await setup() const controller = new AbortController() diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 77bd19ffc4..fde84189cb 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -189,8 +189,8 @@ describe('stdin and extra env (set by in-process plugins)', () => { }) it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { - // The no-stdin path must stay observationally identical to the pre-seam `ignore` default: a - // command that probes stdin's file type sees a char device (/dev/null). + // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). + // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done expect(none.stdout.text).toBe('char\n') const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done @@ -215,8 +215,8 @@ describe('stdin and extra env (set by in-process plugins)', () => { }) it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { - // The child exits immediately without reading; closing our end of a stdin pipe still - // holding ~1MiB triggers EPIPE on the write. + // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. + // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) const result = await runBash(spec('exit 7', { stdin: big })).done expect(result.exitCode).toBe(7) @@ -355,7 +355,7 @@ describe('abort edge cases', () => { }) }) -describe('review fixes: env scrubbing and spill hardening', () => { +describe('environment and spill-file hardening', () => { it('scrubs credential-shaped env vars from child processes', async () => { process.env.DSH_TEST_API_KEY = 'super-secret' process.env.DSH_TEST_TOKEN = 'also-secret' diff --git a/packages/bash/bash-sandbox/README.md b/packages/bash/bash-sandbox/README.md index 25782f6bd5..e418d41cfe 100644 --- a/packages/bash/bash-sandbox/README.md +++ b/packages/bash/bash-sandbox/README.md @@ -14,7 +14,7 @@ Semantics: - **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI). - **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker. -- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. +- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. The model learns standing mode only from tool/result facts, not a system-prompt announcement. - **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce. - Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/). diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 33bb8eff4c..948bf5cccf 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -1,6 +1,9 @@ /** - * `SandboxBashExecutor`: the sandbox-consuming implementation of the `@deepseek-ai/dsh-bash` - * executor seam. + * Sandbox-consuming bash executor. It wraps the exact local bash argv through + * `ctx.sandbox`, inherits local process mechanics, and reports the selected + * mode, enforcement, and denial facts. Runner failure means the command never + * ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background + * tasks carry `runnerFailed`. The tool owns approval and passes per-call modes. * @module @deepseek-ai/dsh-bash-sandbox */ @@ -42,7 +45,9 @@ export function shellQuote(text: string): string { } /** - * Classify a nonzero run using the selected backend's denial signatures. + * Conservatively classify a nonzero, non-signal run using only the selected + * backend's denial signatures. Text inference may miss a denial or match + * unrelated stderr in that dialect; it never uses another backend's terms. * @param result - the settled foreground run to classify. * @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings. * @returns whether the run's failure reads as a sandbox denial. @@ -52,7 +57,9 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin } /** - * Classify a nonzero run using the selected backend's runner-failure signatures. + * Classify a nonzero run using the selected backend's runner-failure + * signatures. Callers check this before denial because runner diagnostics may + * contain denial words; the command did not run. * @param result - the settled foreground run to classify. * @param signatures - the active wrap's runner-failure signatures, * case-insensitive stderr substrings. @@ -75,7 +82,9 @@ function matchesSignature(exitCode: number | null, stderr: string, signatures: r } /** - * Sandbox-consuming bash executor. + * Registers as `ctx.bash` in place of the local executor and consumes a + * `ctx.sandbox` provider. Its configured mode is the fallback; each resolved + * call may carry a session override or approved one-shot escalation. */ export class SandboxBashExecutor extends LocalBashExecutor { static inject = ['sandbox'] @@ -93,9 +102,9 @@ export class SandboxBashExecutor extends LocalBashExecutor { private readonly mode: SandboxMode private readonly workspaceRoot: string /** - * Per-task facts, keyed by task id from `start()` until the settle stamp consumes them: the - * mode the task runs under (per-call — an escalated task differs from its neighbors) plus - * its wrap facts. + * Per-task mode and wrap facts retained until settlement. Overlapping tasks + * may use different modes or provider facts, so one latest-wrap field would + * misclassify earlier completions. */ private readonly taskFacts = new Map { }) it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => { - // The seam returns facts per WRAP — a legal provider may vary them between calls. + // Facts belong to each wrap and may vary between calls. The slow task settles after the + // quick task starts; a shared latest-wrap field would classify and stamp it with the wrong + // task's dialect and enforcement. const wraps: Array> = [ { enforcement: 'partial', denialSignatures: ['permission denied'] }, { enforcement: 'full', denialSignatures: ['read-only file system'] }, diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 7aac7086bf..7cc58202fc 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,8 +9,11 @@ import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sand import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' /** - * Keyless macOS integration of the real Seatbelt provider and sandbox executor, - * including world effects and denial classification. Skips when the probe fails. + * Keyless macOS integration of the real provider and executor through public run/start paths. + * Linux rungs are forced off so Seatbelt is selected. The tests check world effects and stamped + * facts, including EPERM classification through the wrap-carried dialect; backend-only + * confinement is covered by `@deepseek-ai/dsh-sandbox-local`. Skips off macOS or when + * `sandbox-exec` rejects the profile. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 16f12069c0..67e36385f0 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -29,9 +29,11 @@ declare module 'cordis' { } /** - * Abstract bash execution service. Subclass, implement the abstract methods, and load the - * subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a - * second throws, which is cordis' standard duplicate-service behavior). + * Registers one `ctx.bash` implementation. Runtime command failures resolve as + * {@link BashRunResult}; only infrastructure failures reject. Background starts + * return immediately without a timeout, report completion exactly once while + * live, and remain cancellable by signal or {@link kill}. Output reads are + * incremental and flag lost buffered data; disposal kills and awaits all tasks. */ export abstract class BashExecutor extends Service { private listeners = new Set() @@ -51,7 +53,8 @@ export abstract class BashExecutor extends Service { * The sandbox mode this executor confines commands under BY DEFAULT, or `undefined` when it * does not sandbox at all — the capability fact the tool and ACP layers read to advertise * sandbox controls honestly. - * + * A session or call may override this default, so widening is evaluated per + * execution rather than encoded in this getter. * @returns the configured default mode of a sandboxing executor; * `undefined` for an executor that never confines. */ @@ -92,7 +95,8 @@ export abstract class BashExecutor extends Service { /** * The opaque OWNER token recorded for a background task at {@link start} (from the {@link * BashExecSpec}'s `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. - * + * The executor stores the token without interpreting policy; keeping it here + * lets ownership survive a consumer-plugin reload. * @param id - the background task id to look up ownership for. * @returns the token recorded at start, verbatim; undefined for an unknown * id or a known-but-ownerless task. diff --git a/packages/bash/bash/src/session-mode.ts b/packages/bash/bash/src/session-mode.ts index 79b65cfdb9..2ffcf2d273 100644 --- a/packages/bash/bash/src/session-mode.ts +++ b/packages/bash/bash/src/session-mode.ts @@ -1,5 +1,8 @@ /** - * Per-session sandbox-mode override: the session log as the store. + * Per-session sandbox-mode override stored as log-only events. Folding the log + * isolates sessions and survives replay; the tool stamps the result onto each + * call unless a one-shot escalation grant overrides it. The model sees the + * effective mode through prompt guidance and boundary notices, not the event. * @module dsh-bash/session-mode */ diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 565d687745..d0cb702100 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -119,8 +119,9 @@ export interface BashExecRequest { */ owner?: OwnerToken | undefined /** - * Explicit per-call sandbox-policy input, overriding the executor's configured default mode - * for this call. + * Explicit per-call sandbox policy. The tool stamps a session override or a + * one-shot approved escalation, with the grant taking precedence. Sandboxing + * executors honor it for this call; non-sandboxing executors do not confine. */ sandboxMode?: SandboxMode | undefined } diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 2f54477a6c..9540be1d1a 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -4,7 +4,7 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). -The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Under a sandboxing executor it additionally contributes the per-agent `env:bash-sandbox` section (order 110) stating each session's EFFECTIVE mode, and the pre-step narrator — see [Per-session mode](#per-session-mode-switching-and-visibility). +The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. Sandbox mode is intentionally learned from denial results, not announced in the prompt; see [Per-session mode](#per-session-mode-switching). ## Tools diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 7368059d1c..d7b8c8d27e 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -1,8 +1,7 @@ /** - * The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure schema + text shaping - * — every process concern lives behind the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`), - * so sandbox/permission/remote executor implementations swap in without touching what the - * model sees. + * Model-facing `bash`, `bash_output`, and `bash_kill` tools over the executor + * seam. Background tasks are fenced by owning session, completion injects a + * durable notice, and confining executors add one-shot approval-based escalation. * @module @deepseek-ai/dsh-tool-bash */ @@ -25,7 +24,7 @@ export const name = 'tool-bash' export const inject = ['tools', 'bash', 'systemPrompt'] /** - * Validate the constraints the SchemaSpec can't express. `defineTool` now + * Validate the constraints the SchemaSpec can't express. `defineTool` * validates parsed args against the SchemaSpec before `execute` runs (the * arg-validation RFC), so type/required/enum checks are already done and `args` * is the validated `InferArgs` shape here. What remains are value constraints @@ -136,8 +135,9 @@ function streamText(output: CollectedOutput): string { } /** - * Shape one finished run into the text the model sees: stdout, then a marked stderr - * section, then exit-status markers. + * Shape one finished run into model-visible stdout, marked stderr, and status + * facts. Non-zero exits and sandbox denials remain ordinary results; only + * infrastructure failure or abort makes the tool call itself fail. * * @param result - the completed foreground run from the executor. * @param escalationModes - the escalation targets this composition advertises; non-empty @@ -193,7 +193,7 @@ export function renderResult( // UI presentation (tool-owned). /** - * Pending-state presentation for a `bash` call. + * Present foreground calls as terminals and background starts as generic cards. */ type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean } @@ -220,7 +220,8 @@ function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView } /** - * Completed-state presentation for a `bash` call. + * Present completed foreground output as a terminal; background acknowledgements + * and execution errors use generic fenced output without an exit-status pill. */ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | undefined { const block = result.content.length === 1 ? result.content[0] : undefined @@ -238,8 +239,8 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | } /** - * Recover the structured exit status from a rendered `renderResult` string — the inverse of - * the status markers it appends. + * Recover exit status from the final marked line emitted by {@link renderResult}. + * A program whose own final line exactly mimics a marker remains ambiguous for UI display. */ function parseExitStatus(text: string): { exitCode: number } | { signal: string } { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) @@ -255,7 +256,8 @@ function presentTaskCall(verb: string, args: { task_id: string }): GenericCallVi } /** - * Resolve the working directory for a bash call. + * Resolve an explicit workdir first, making a relative one session-cwd-relative; + * otherwise use the session cwd and leave executor defaulting as the fallback. */ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined { const sessionCwd = exec.agent?.session.header.cwd @@ -314,7 +316,8 @@ export function apply(ctx: Context): void { } } - // Background completion → inject a notice into the owning agent's session. + // Completion runs on the bash fiber, so use topology-independent lookup and + // match the executor's stored session-owner token to a live agent. ctx.bash.onTaskDone((task) => { const ownerToken = ctx.bash.ownerOf(task.id) if (ownerToken === undefined) return diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index ab2eaa2b3f..0bc30a5436 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -56,7 +56,8 @@ async function setup() { */ const fakeAgentDisposers = new Map Promise | void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { - // Distinct ids ensure notices match the session owner token, not the registry key. + // A config agent has distinct registry (`agent.id`) and owner (`session.header.id`) tokens. + // Keeping them unequal makes notice lookup by the wrong field fail instead of passing by chance. const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent const dispose = ctx.agents.register(agent) const list = fakeAgentDisposers.get(ctx) ?? [] @@ -411,8 +412,8 @@ describe('background tools', () => { it('injects a completion notice into the owning agent (found via the registry by session token)', async () => { const ctx = await setup() const inject = vi.fn() - // The notice path looks the agent up in ctx.agents by its session token, so the agent must - // be REGISTERED (not merely passed to execute). + // Notices look up the agent in ctx.agents by session token, so passing it to execute is not + // enough: the fake must be registered with a matching `session.header.id`. const agent = registerFakeAgent(ctx, 'bg', inject) const started = await ctx.tools.execute({ @@ -475,9 +476,8 @@ describe('background tools', () => { }) it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => { - // A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its per-session agent - // — e.g. the ACP session disconnects and its AgentHandle disposes while the background task - // is still running. + // Host-scoped bash tasks can outlive a per-session agent after an ACP disconnect. The task + // retains its owner token, but with no matching live agent the notice is dropped without error. const ctx = await setup() const inject = vi.fn() const agent = registerFakeAgent(ctx, 'bg', inject) @@ -507,9 +507,8 @@ describe('background task ownership (cross-session isolation)', () => { function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) { return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } - // Ownership is by TOKEN (session.header.id), not agent object identity — so each agent needs - // a DISTINCT session id, else every fake yields the same token and the isolation tests pass - // for the wrong reason (all tasks owned by the same token). + // Ownership uses `session.header.id`, not object identity. Distinct ids keep the isolation tests + // from passing accidentally because every fake produced the same owner token. const fakeAgent = (sessionId: string) => ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent @@ -589,8 +588,8 @@ describe('background task ownership (cross-session isolation)', () => { }) it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => { - // The owner token lives on the TASK inside the executor (dsh-bash fiber), not in a - // tool-bash plugin-local map. + // The executor task owns the token, so reloading only tool-bash preserves ownership. A + // plugin-local map would lose it and incorrectly expose the task to agent B. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -809,9 +808,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => { const ctx = await setup() const args = { command: 'printf "[exit code: 5]"', description: 'print' } - // A successful command can print text that looks like a marker. renderResult for a clean - // exit 0 appends NOTHING (and no trailing newline), so the body's own tail is `[exit code: - // 5]`. + // A successful command may print marker-like text. A clean result appends no marker or + // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) // Same for a fake signal marker with no leading newline. @@ -874,17 +872,18 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => { const ctx = await setup() - // defineTool wraps presentCall to soft-validate against the schema and fall back to - // undefined (a generic UI presentation) rather than throwing on the display path — it may - // run on replay of arbitrary logged args. + // `defineTool` soft-validates replayed logged args before presentation. Invalid shapes return + // undefined for generic UI rendering rather than throwing; `presentCall` accepts `unknown`. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined() }) }) describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => { /** - * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a test can - * assert what the model-facing tool DID and DID NOT forward. + * Records requests passed to `resolve()` so tests can prove the model-facing tool forwards only + * named arguments. It intentionally exposes neither `stdin` nor `env`; this catches a future + * `...args` spread into the post-scrub env merge. The credential scrub remains the security + * boundary; see the bash stdin/env RFC. Foreground `run()` is canned and `start()` is unused. */ class RecordingBashExecutor extends BashExecutor { readonly requests: BashExecRequest[] = [] @@ -927,7 +926,9 @@ describe('the model-facing bash tool builds its request from named args only (no it('does not forward env/stdin even when the model includes them as extra arguments', async () => { const { ctx, bash } = await setupRecording() - // Extra args: the model includes `env` and `stdin` keys hoping they reach the executor. + // Unknown `env` and `stdin` keys are ignored by the schema and named request construction. + // This preserves the request shape; it is not a security boundary because shell syntax can + // already set environment variables or feed stdin. await ctx.tools.execute({ callId: CallId('no-forward-1'), name: 'bash', @@ -1418,8 +1419,9 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => { }) it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => { - // The blocker scenario: a workspace-write default with a read-only override — the sensible - // escalation is workspace-write, which a default-relative ladder could not even express. + // With a workspace-write default and read-only override, escalation must return to + // workspace-write. The static target vocabulary exposes it, and validation compares it with + // the call's effective override rather than a default-relative ladder. const ctx = await setupModal('workspace-write', { approval: true }) ctx.on('approval/request', () => Promise.resolve('allowed-once')) const seen: (string | undefined)[] = [] diff --git a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts index f4a29504f2..db78f9d72e 100644 --- a/packages/code-runtime/code-runtime-worker/src/bootstrap.ts +++ b/packages/code-runtime/code-runtime-worker/src/bootstrap.ts @@ -92,7 +92,9 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS) /** * Redirect a stream's `write` into the log buffer (the program-visible * `process.stdout`/`process.stderr` in the real worker), so raw writes land in emission order - * alongside console output instead of racing down a pipe. + * alongside console output instead of racing down a pipe. It preserves Node's optional callback + * contract: the callback runs asynchronously after admission, even when the log budget drops + * the write. * * @param logs - the buffer captured writes are pushed into. * @param stream - the stream whose `write` slot is patched. @@ -147,7 +149,8 @@ export function truncateUtf8Bytes(text: string, maxBytes: number): string { * Prepare the program's completion value for the done message: a value whose MEASURED * cross-boundary size fits `maxValueBytes` crosses raw — exact bytes for a string, the * structured-clone wire size (`v8.serialize`) for everything else, so a huge container whose - * BOUNDED inspect rendering happens to be small cannot smuggle itself past the cap. + * bounded inspect rendering happens to be small cannot smuggle itself past the cap. Oversized + * or non-cloneable values are replaced by a bounded string rendering with an in-band marker. * * @param value - the program's completion value. * @param maxValueBytes - the byte cap for the value. @@ -205,6 +208,7 @@ export function wireReplies(port: BootstrapPort, pending: Map { @@ -315,9 +315,8 @@ export class WorkerCodeRuntime extends CodeRuntime { worker.stdout.on('data', captureStray('stdout')) worker.stderr.on('data', captureStray('stderr')) - // Settlement: exactly one outcome wins; every path funnels through here, cleans up the - // timers/listeners, terminates the worker, and resolves only after the worker actually - // exited (quiescence). + // Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker; + // logs captured before timeout, abort, or failure remain in the result. let finishResolve!: () => void const finished = new Promise((done) => { finishResolve = done }) const finish = (result: Omit): void => { @@ -335,9 +334,8 @@ export class WorkerCodeRuntime extends CodeRuntime { const onDone = (message: WorkerToHost): void => { if (message.type !== 'done') return - // Re-cap the completion value HOST-side: the honest path already capped it in the - // worker (prepareValue there), but a forged done message bypasses the bootstrap - // entirely — without this, model code could flood the host past maxValueBytes. + // Re-cap forged completion traffic at the hostile boundary. Honest worker-capped values + // pass unchanged via VALUE_RENDER_SLACK; error text is bounded too. finish({ ...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK), ...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {}, diff --git a/packages/code-runtime/code-runtime-worker/src/protocol.ts b/packages/code-runtime/code-runtime-worker/src/protocol.ts index cb75321d4a..739f1e4eb5 100644 --- a/packages/code-runtime/code-runtime-worker/src/protocol.ts +++ b/packages/code-runtime/code-runtime-worker/src/protocol.ts @@ -1,5 +1,7 @@ /** - * Wire protocol between the host runtime and the worker bootstrap. + * Versionless, structured-clone wire protocol between co-shipped host and worker code. The host + * treats inbound traffic as hostile because model code can forge `parentPort` messages; the + * worker trusts host replies. * @module @deepseek-ai/dsh-code-runtime-worker/src/protocol */ diff --git a/packages/code-runtime/code-runtime-worker/src/worker.ts b/packages/code-runtime/code-runtime-worker/src/worker.ts index 12e74310cd..f7a164f0ef 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker.ts @@ -1,6 +1,6 @@ /** - * The worker-thread entrypoint: self-executing glue over `bootstrap.ts`'s {@link - * runWorkerMain}, kept to the spawn wiring alone. + * Spawn-only worker entrypoint over {@link runWorkerMain}. Executable logic stays in + * `bootstrap.ts` for in-process coverage; real-worker tests cover this glue. * @module @deepseek-ai/dsh-code-runtime-worker/src/worker */ diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index d420790441..6a86624f7a 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -5,10 +5,9 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' /** - * Built-ARTIFACT smoke for the published package (the real-load-path guard from - * docs/testing.md): the unit suite runs `src/` under vitest, where the worker entry resolves - * to `src/worker.ts` — a consumer runs `lib/index.js` under plain `node`, where it must - * resolve the sibling `lib/worker.js` bundle instead. + * Keyless built-artifact smoke: plain Node imports the package by name through its exports map + * and exercises type stripping, worker loading, bindings, and logs. It skips when `lib/` is + * absent; CI runs it after the build. */ const pkgDir = fileURLToPath(new URL('..', import.meta.url)) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 2167bdbd20..a7386938c5 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -255,8 +255,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => { it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => { const { runtime } = await setup({ maxLogBytes: 4 }) const result = await runtime.run({ - // The bootstrap patches the stream instance's own `write`; going through the prototype's - // slot reaches the real pipe underneath, so the bytes arrive host-side as stray data. + // The prototype write bypasses the patched instance and reaches the real pipe. Pauses keep + // writes in separate chunks and let both reach the host before settlement. program: ` const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text); write('abcd'); diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 5e1e5099c5..a481eac481 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -1,10 +1,9 @@ import { defineConfig } from 'tsdown' /** - * Package-shape override (see the root tsdown.config.ts): besides the default lib/index.js - * bundle, the worker BOOTSTRAP ships as its own sibling entry — `new Worker(new - * URL('./worker.js', import.meta.url))` loads it as a file, so it cannot be part of the index - * bundle. + * Build the index and worker as separate single-entry bundles. The worker must be a sibling + * file, while a multi-entry build would emit an unlisted shared chunk omitted by the package's + * exact `files` whitelist. */ export default defineConfig([ { diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index e06fecc8c9..e629a855b5 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -1,5 +1,6 @@ /** - * Code-execution seam for running one model-written program against host bindings. + * Code-execution seam for running one model-written program against host async bindings. + * Runtimes know nothing about tools or sessions; consumers own those concerns. * @module @deepseek-ai/dsh-code-runtime */ @@ -22,9 +23,10 @@ declare module 'cordis' { } /** - * Abstract code-execution service. Subclass, implement {@link 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). + * Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate + * failures resolve in {@link CodeRunResult}; only seam misuse rejects. Implementations bridge + * structured-cloneable bindings while treating programs as hostile peers, isolate runs from + * one another, and terminate and await in-flight runs during disposal. */ export abstract class CodeRuntime extends Service { /** diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 4a2956d0de..582c24abad 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,12 +8,12 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: -- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix, derived history, and system prompt. -- **Retention** — compact the oldest whole surface units while preserving a recent tail and tool-call/result pairing. Turn boundaries do not protect old steps inside a runaway turn. An indivisible unit larger than the budget remains out of scope. -- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source. -- **Summarization** — a direct `llm/stream` call uses the configured model and cap. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls. +- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state. +- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. +- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. +- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. -- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step so the loop derives history once after mutation. +- **Lifecycle** — `compactRegion()` records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged. `estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index bfe4fb927c..bf349f7f73 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -1,6 +1,8 @@ /** - * `BasicCompactService`: the first implementation of the `@deepseek-ai/dsh-compact` seam. It - * owns the entire compaction strategy. + * Basic compaction backend. It estimates request pressure, retains a recent + * tool-balanced surface tail, summarizes the older head through a one-shot model + * call, and replaces that head with one checkpoint. Auto-compaction runs before + * every step so a growing turn can compact its earlier closed steps. * @module @deepseek-ai/dsh-compact-basic */ @@ -29,8 +31,8 @@ const SUMMARY_OPEN_TAG = '' const SUMMARY_CLOSE_TAG = '' /** - * The summarization system prompt: instructs the model to condense the conversation into a - * fixed, fully-populated structure rather than freeform bullets. + * Fixed summary structure for resumable checkpoints. A tagged prior checkpoint + * is merged with newer history instead of copied forward verbatim. */ const SUMMARIZE_SYSTEM_PROMPT = [ 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', @@ -73,8 +75,8 @@ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' /** - * Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or `undefined` for an - * acceptable finish. `FinishReason` is merge-extensible. + * Map a terminal summary failure to an error. A max-token finish is rejected + * because committing an incomplete checkpoint would shadow the full history. */ function finishError(finish: FinishReason): Error | undefined { switch (finish.kind) { @@ -116,7 +118,8 @@ export class BasicCompactService extends CompactService { this.config = resolveConfig(config) if (this.config.auto) { - // Auto-compaction: delegate to compactIfNeeded before every step. + // Check before every step so a single growing turn can compact earlier closed steps. + // This serial pre-step seam mutates the surface outside the pending step. ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => { try { const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) @@ -223,8 +226,9 @@ export class BasicCompactService extends CompactService { } /** - * Summarize conversation text into content blocks via `ctx.llm.stream()` assembled through a - * `BlockAssembler`. + * Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent + * step or `agent/request` dispatch. Failure finishes and truncated summaries + * reject; the signal is forwarded and only text reaches the checkpoint. * * @param text - plain-text rendering of the conversation region to condense. * @param agent - supplies the fallback model and the session id stamped on @@ -274,10 +278,10 @@ export class BasicCompactService extends CompactService { // ---- Core API (implements the abstract contract) ---- /** - * The sole token-pressure gate: estimate the NEXT request's pressure — the session prefix + - * the surface-derived history + the system prompt ({@link estimatePressure}) — and if it - * exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest surface nodes - * outside the `retainTokens` budget. + * The sole pressure gate: count the next request's prefix, derived history, + * and system prompt. Above threshold, retain a recent tool-balanced tail and + * compact the head, reconsolidating any prior automatic checkpoint. Returns + * `null` when no safe or necessary range exists. */ override async compactIfNeeded( agent: Agent, @@ -333,7 +337,7 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise { - // Resolve the range by surface POSITION, not numeric seq interval. + // Resolve by surface position: a newer replacement seq may occupy an older slot. const nodes = session.surface.nodes const startIdx = nodes.findIndex(n => n.seq === start) const endIdx = nodes.findIndex(n => n.seq === end) @@ -343,8 +347,7 @@ export class BasicCompactService extends CompactService { throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`) } - // The region must never split a step's assistant-message tool-calls from their tool/results - // (which would orphan one side and produce a transcript every provider rejects). + // Both range edges must preserve assistant tool-call/result pairing. const events = session.events if (!isToolPairingBalanced(nodes, events, start)) { throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 75b7ab0f6f..8c5cc183c1 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -203,7 +203,8 @@ function expectNoOrphanToolResults(messages: Message[]): void { describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => { it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => { - // 3 turns, each one step = { assistant(tool-call), tool/result }. + // Retain the recent tail while the older assistant/result pairs compact as + // whole units; no boundary may orphan a result. const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 }) const session = toolTurnSession(3) @@ -218,7 +219,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai }) it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => { - // The surface is exactly one step: [assistant(tool-call), tool/result]. + // The only candidate cut is inside one assistant/result pair; with no safe + // compactable prefix, decline rather than split it. const s = new Session(SessionId('one-step')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) @@ -587,14 +589,16 @@ describe('BasicCompactService.compactIfNeeded', () => { }) it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => { - // threshold = floor(480*0.1) = 48. + // Role overhead pushes the request above its 48-token threshold, but the + // raw four-node retention walk remains below retainTokens=45, so all fit. const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 }) const session = multiTurnSession(2, 1) expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull() }) it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => { - // The Regression that motivated dropping turn-protection. + // Completed early steps of the open turn remain eligible; protecting the + // whole turn would make a runaway turn impossible to compact. const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 }) const s = new Session(SessionId('runaway')) // ONE open turn with 5 closed steps; each step is [asst(tool-call), result]. @@ -740,8 +744,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => { }) it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => { - // A crash mid-compaction left a compact/start with no compact/end; the turn it lived in was - // later closed (persistence repair appends turn/end). + // An orphaned start in a closed repaired turn is stale; only the current + // turn participates in the in-progress lock. const svc = createTestService() const s = new Session(SessionId('stale-lock')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -1218,7 +1222,8 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () => it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => { const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model') - // One-shot summaries use llm/stream, not the loop's agent/request seam. + // One-shot summaries bypass agent/request but remain mutable at llm/stream; + // adapter selection happens after the waterfall rewrite. ctx.on('llm/stream', (options, next) => { options.model = 'routed-model' return next() @@ -1472,16 +1477,13 @@ describe('BasicCompactService edge cases', () => { const svc = createTestService() const s = new Session(SessionId('empties')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Step 1: an empty-text user, an empty-reasoning assistant with NO tool-call - // (balanced: nothing to answer), and empty context/steering — all extract to - // nothing and are skipped. s.append('step/start', { turn: 1, step: 1 }) s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' }) s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/end', { turn: 1, step: 1 }) - // Step 2: a tool exchange whose tool/result has empty content → empty extraction → skipped. + // Keep the log pairing-valid while the empty result covers the final message kind. s.append('step/start', { turn: 1, step: 2 }) s.append('assistant/message', { turn: 1, step: 2, @@ -1495,10 +1497,6 @@ describe('BasicCompactService edge cases', () => { const nodes = s.surface.nodes await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') - // Every empty-content message (user text, empty reasoning, empty-content - // tool/result, empty context, empty steering) extracted to nothing and was - // skipped — the only surviving line is the assistant's tool-call (which a - // balanced surface requires to answer the tool/result). expect(svc.summarizeCalls[0]!.text).toBe('Assistant: [tool-call: bash({})]') }) @@ -1546,31 +1544,26 @@ describe('BasicCompactService edge cases', () => { describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => { it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => { - // A replace inserts the new summary node (a high seq) AT the shadowed range's surface - // position, so the surface becomes [highSeqSummary, …olderRetainedLowerSeqs]. + // Replacement makes surface seqs non-monotonic. The next region is a + // positional span even when startSeq > endSeq. const svc = createTestService({ auto: false }) const session = multiTurnSession(4, 1) - // First compaction: shadow the two oldest surface nodes. + // A replacement puts its high-seq summary at the surface head. const nodes0 = session.surface.nodes const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm') - // The summary node now sits at the head with a seq HIGHER than the retained older nodes - // that follow it — the non-monotonic surface. const nodes1 = session.surface.nodes expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq) expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq) - // Second compaction: shadow [summary(head) … turn-2's step end]. const startSeq = nodes1[0]!.seq const endSeq = nodes1[2]!.seq expect(startSeq).toBeGreaterThan(endSeq) const second = await compactRegion(svc, session, startSeq, endSeq, 'm') - // Exactly the three nodes at surface positions [0..2] are shadowed, in - // surface order — the positional slice, regardless of their seq values. + // Selection follows surface positions, not sequence-number order. expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq]) - // The surface still derives cleanly: a new head replace node + the rest. const finalNodes = session.surface.nodes expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq) expect(session.deriveMessages().length).toBe(finalNodes.length) @@ -1580,20 +1573,15 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const svc = createTestService({ auto: false }) const session = multiTurnSession(3, 1) - // First compaction shadows the oldest two surface nodes, landing a high-seq - // summary node at the head. + // Put a high-seq summary at the head; log order would place retained older nodes first. const n0 = session.surface.nodes await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm') - // Second compaction spans [head summary … turn-2's step end]. The head's seq - // is higher than the older retained nodes' seqs, so a log-seq-order walk - // would emit the older messages BEFORE the checkpoint. const n1 = session.surface.nodes svc.summarizeCalls = [] await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm') - // The extracted transcript follows surface order: the checkpoint (head) - // first, then the older retained messages — matching deriveMessages(). + // Extraction must match surface and `deriveMessages()` order. const { text } = svc.summarizeCalls[0]! const checkpointIdx = text.indexOf('compacted-summary') const olderIdx = text.indexOf('turn 2 user') diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index c7e5df12ac..dbf8a3b737 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -14,9 +14,10 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' /** - * CBR-001 regression: a compaction checkpoint that the real loop lands is a free surface - * boundary (it carries no tool-call/result pair), so it must be a valid region edge on BOTH - * sides. + * CBR-001 regression through the real loop. A replacement checkpoint has a high + * log seq at the surface head and carries no tool pair, so both adjacent cuts + * must be safe and re-compacting that checkpoint alone must succeed. This pins + * surface-position semantics rather than raw-log scanning. */ const TOKENS_PER_BLOCK = 10 @@ -117,9 +118,8 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () ) expect(checkpoints.length).toBeGreaterThan(0) - // The loop fired compaction mid-flight, so each landed checkpoint sits at a high log seq - // beside the step it landed in, even though its surface position is the head of the range - // it shadowed. + // High log position does not make a text-only checkpoint mid-step; both + // its start and end cuts are balanced in surface order. const nodes = agent.session.surface.nodes for (const cp of checkpoints) { const node = nodes.find(n => n.seq === cp.seq) diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index c0cff9c083..8cf3afc527 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -59,7 +59,7 @@ export abstract class CompactService extends Service { * @param start - first surface seq, inclusive. * @param end - last surface seq, inclusive. * @param agent - summarizer context. - * @param signal - optional cancellation. + * @param signal - optional cancellation; model-backed implementations must forward it. * @throws when compaction is active or the range is invalid or unbalanced. * @returns the replaced range and summary. */ diff --git a/packages/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts index 78bc3988bc..48006d13b7 100644 --- a/packages/compact/compact/src/render.ts +++ b/packages/compact/compact/src/render.ts @@ -1,7 +1,6 @@ /** - * Plain-text transcript rendering over session events: the shared projection used wherever a - * compaction-class consumer needs "what a model once saw" as readable text — a summarizer's - * input, or a recall tool's output. + * Pure shared transcript projection for summarization and recall, so both + * render the same log span byte-for-byte under replay. * @module @deepseek-ai/dsh-compact/render */ @@ -9,7 +8,9 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' /** - * Render content blocks to a single plain-text string. + * Render text directly, reasoning as a tagged span, and every other block as a + * type-tagged placeholder. Tool results recurse into nested content; empty + * blocks contribute nothing and rendered blocks join with newlines. * * @param blocks - the content blocks to render. * @returns the newline-joined plain-text rendering; empty string when nothing renders. @@ -43,7 +44,9 @@ export function renderContentBlocks(blocks: readonly ContentBlock[]): string { } /** - * Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:` transcript. + * Render message-producing events as a role-labeled transcript. `seqs` are + * walked in caller-supplied surface order, which may differ from numeric log + * order after replacement; non-surface and unknown merged events are skipped. * * @param events - the session log the seqs index into (`session.events`). * @param seqs - the surface-node seqs to render, in surface order. diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index e13b00f4d9..10a5eabfcc 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -1,5 +1,9 @@ /** * Compaction vocabulary: the result type and the `compact/*` session events. + * Those declaration-merged events are log-only lock/provenance markers, not + * surface events; a separate replacement `user/message` carries the summary. + * Backend packages own configuration and retention policy; see + * `docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md`. * @module @deepseek-ai/dsh-compact/types */ diff --git a/packages/cordis/tool-cordis/README.md b/packages/cordis/tool-cordis/README.md index 5eca77cd1e..fb1807cf2b 100644 --- a/packages/cordis/tool-cordis/README.md +++ b/packages/cordis/tool-cordis/README.md @@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata ## Trust stance -The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services, and writes to `globalThis` stay local, but host-realm helpers and the privileged context make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access. +The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Config diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a77a186fb3..37ef984304 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -84,7 +84,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'bash', - summary: 'Abstract bash execution service.', + summary: 'Registers one `ctx.bash` implementation.', methods: [ 'abstract resolve(request: BashExecRequest): BashExecSpec', 'abstract run(spec: BashExecSpec): Promise', @@ -99,7 +99,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'codeRuntime', - summary: 'Abstract code-execution service.', + summary: 'Registers one `ctx.codeRuntime` implementation.', methods: [ 'abstract run(request: CodeRunRequest): Promise', ], @@ -114,7 +114,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'fs', - summary: 'Abstract filesystem provider service.', + summary: 'Abstract filesystem provider.', methods: [ 'abstract resolve(path: string, opts?: { cwd?: string }): Promise', 'abstract stat(target: FsTarget, signal?: AbortSignal): Promise', @@ -240,7 +240,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'agent/created', mode: 'emit', signature: '\'agent/created\'(this: Scoped, agent: Agent): void', - summary: 'A fully configured agent and its session were published.', + summary: 'A fully configured agent and live session were published.', }, { name: 'agent/disposed', @@ -324,19 +324,19 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'fs/edit-intent', mode: 'waterfall', signature: '\'fs/edit-intent\'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>', - summary: 'Single-slot decision: produce the optional version guard for the next FileSystem.editText.', + summary: 'Single-slot decision for the next FileSystem.editText.', }, { name: 'fs/observed', mode: 'emit', signature: '\'fs/observed\'(target: FsTarget, version: FsVersion, actor: object | undefined): void', - summary: 'Record that an actor observed a target at a version, after a successful read/write/edit.', + summary: 'Record a successful observation.', }, { name: 'fs/write-intent', mode: 'waterfall', signature: '\'fs/write-intent\'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise', - summary: 'Single-slot decision: produce the write intent for the next FileSystem.writeText.', + summary: 'Single-slot decision for the next FileSystem.writeText.', }, { name: 'llm/stream', @@ -348,25 +348,25 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'session/created', mode: 'emit', signature: '\'session/created\'(this: Scoped, session: Session): void', - summary: 'Emitted after session publication.', + summary: 'Creation announcement during session publication.', }, { name: 'session/disposed', mode: 'emit', signature: '\'session/disposed\'(this: Scoped, session: Session): void', - summary: 'Emitted once when an announced session leaves the store, including publication rollback.', + summary: 'Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin.', }, { name: 'session/event', mode: 'emit', signature: '\'session/event\'(this: Scoped, session: Session, event: SessionEvent): void', - summary: 'Post-commit append feed.', + summary: 'Post-commit, fire-and-forget append feed.', }, { name: 'session/flush', mode: 'parallel', signature: '\'session/flush\'(this: Scoped, session: Session): Promise | void', - summary: 'Awaited parallel durability checkpoint; dispatch through SessionStore.flush.', + summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.', }, { name: 'skill/provider-added', diff --git a/packages/cordis/tool-cordis/src/fiber-state.ts b/packages/cordis/tool-cordis/src/fiber-state.ts index 103c979f0f..8c9f0e2f45 100644 --- a/packages/cordis/tool-cordis/src/fiber-state.ts +++ b/packages/cordis/tool-cordis/src/fiber-state.ts @@ -1,7 +1,7 @@ /** - * Runtime mirror of the cordis `FiberState` const enum plus human-readable labels, shared by - * the mount lifecycle (state reporting) and the inspect renderers (plugin-list and mount-table - * labels). + * Runtime mirror and labels for Cordis's `FiberState` const enum. A const enum has no runtime + * object to import, so these values mirror the pinned vendored definition while retaining its + * type. * @module @deepseek-ai/dsh-tool-cordis/fiber-state */ diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts index 5ccefe5009..2198533376 100644 --- a/packages/cordis/tool-cordis/src/guard.ts +++ b/packages/cordis/tool-cordis/src/guard.ts @@ -3,7 +3,12 @@ * normalization + validation with teaching errors, the marker-guarded `harness.defineTool` / * `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives * in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox - * return values with. + * return values with. The façade is a whitelist of lifecycle-safe verbs and declared services; + * framework internals and context-valued service returns are denied. + * + * VM-realm schemas are rebuilt as host objects, and tool results are JSON-round-tripped and + * shape-checked before session logging. Common JSON-Schema spellings are normalized when they + * have one meaning; invalid vocabulary fails during registration with a teaching error. * @module @deepseek-ai/dsh-tool-cordis/guard */ @@ -61,7 +66,7 @@ function normalizeSchemaProp(value: unknown, path: string, forceRequired = false } // On an object property a JSON-Schema-style `required` ARRAY names required // children (handled by the nested unwrap below); everywhere else `required` - // must be a boolean, and `false` simply reads as optional. + // must be a boolean, and `false` means optional. const nestedRequiredArray = type === 'object' && Array.isArray(value.required) if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) { throw new Error(`harness.defineTool ${path}.required must be a boolean when present`) @@ -157,8 +162,8 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn { * The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized * into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped, * `required: false` dropped) and the tool's `execute` return normalized into the host realm - * via a JSON round-trip (see the module doc). - * + * via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning + * the session log. * @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper. * @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts. */ @@ -266,7 +271,8 @@ function declaredInjects(ctx: Context): Set { } /** - * The sandbox context façade handed to a mounted plugin's `apply` in place of the real `ctx`. + * Whitelist context for mounted plugins: lifecycle-safe verbs, guarded tools, and only declared + * injected services. Framework plumbing is denied, and service methods cannot return a Context. */ function sandboxContext(ctx: Context): Context { const tools = sandboxTools(ctx) diff --git a/packages/cordis/tool-cordis/src/index.ts b/packages/cordis/tool-cordis/src/index.ts index a8c75a4251..321546c8c3 100644 --- a/packages/cordis/tool-cordis/src/index.ts +++ b/packages/cordis/tool-cordis/src/index.ts @@ -1,6 +1,9 @@ /** - * The self-referential cordis toolset: three model-facing tools that let the agent inspect and - * MODIFY the live cordis runtime it is running inside. + * Self-referential runtime tools: inspect live services/plugins/tools, mount a returned plugin + * under an owned dynamic fiber, and unmount it to quiescence. Registrations are fiber effects, + * so plugin disposal removes the entire dynamic subtree. The VM and context façade prevent + * accidental misuse, not hostile code: an allowed service such as `ctx.bash` reaches the real + * runtime. Named exports preserve loader injection metadata. * @module @deepseek-ai/dsh-tool-cordis */ diff --git a/packages/cordis/tool-cordis/src/inspect.ts b/packages/cordis/tool-cordis/src/inspect.ts index 097ca41fdf..b483b13712 100644 --- a/packages/cordis/tool-cordis/src/inspect.ts +++ b/packages/cordis/tool-cordis/src/inspect.ts @@ -127,7 +127,9 @@ function typeClosure(seeds: string[], types: readonly TypeApiEntry[]): TypeApiEn } /** - * Render the generated service catalog against the live runtime. + * Render the generated catalog against the live runtime: live catalogued services with methods, + * uncatalogued live services with owners, absent loadable services, referenced type shapes, and + * inherited Context APIs. * @param ctx - the runtime to intersect the catalog with. * @param api - generated service entries, replaceable in tests. * @param inherited - inherited `ctx` entries, replaceable in tests. diff --git a/packages/cordis/tool-cordis/src/mount.ts b/packages/cordis/tool-cordis/src/mount.ts index a811602a2f..fbed4cc161 100644 --- a/packages/cordis/tool-cordis/src/mount.ts +++ b/packages/cordis/tool-cordis/src/mount.ts @@ -21,8 +21,8 @@ export interface DynamicMount { } /** - * Mount a plugin under the group fiber and settle it. - * + * Await the group, mount and settle one guarded child, and dispose it before rethrowing any + * startup failure so a failed mount never lingers. A valid unresolved inject may remain pending. * @param group - the `cordis-dynamic` group fiber every mount hangs under. * @param plugin - the plugin the sandbox returned; wrapped with the registration guard before mounting. * @returns the settled child fiber (possibly pending on unsatisfied `inject`). diff --git a/packages/cordis/tool-cordis/src/sandbox.ts b/packages/cordis/tool-cordis/src/sandbox.ts index 39de411926..99a68b062f 100644 --- a/packages/cordis/tool-cordis/src/sandbox.ts +++ b/packages/cordis/tool-cordis/src/sandbox.ts @@ -2,7 +2,9 @@ * The `node:vm` sandbox `cordis_mount` code evaluates in: a fresh realm whose globals are a * tagged write-through console, the `harness` registration helpers, the encoding primitives a * bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately - * withholds. + * withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`, + * `ctx.bash`, and Cordis timers. This keeps cooperative mounts inspectable and disposable but + * is not containment: host-realm helper functions remain an escape route. * @module @deepseek-ai/dsh-tool-cordis/sandbox */ @@ -23,8 +25,8 @@ function taggedConsole(id: string): Record<'log' | 'info' | 'warn' | 'error' | ' } /** - * Per-sandbox prelude: give the vm realm's own constructors a `Symbol.hasInstance` that checks - * BOTH realms. + * Patch only VM constructors so `instanceof` accepts both VM values and host values passed as + * arguments, events, or service results; host intrinsics remain untouched. */ const DUAL_REALM_INSTANCEOF_PRELUDE = ` (hostIntrinsics) => { @@ -137,8 +139,8 @@ export function syntaxErrorContext(error: Error): string { /** * Evaluate mount code as the body of an async function inside the sandbox. `vmTimeoutMs` only * bounds the SYNCHRONOUS portion; an async body escapes it — acceptable under the module's - * trust stance. - * + * trust stance. Parse errors include the offending line and a TypeScript-removal or bracket- + * balance hint. * @param sandbox - the contextified object from {@link createSandbox}. * @param code - the model-written function body; must `return` a plugin. * @param id - the mount id, used as the vm filename (`cordis-mount-.js`). diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index 880e7e07b0..09011c77de 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -48,7 +48,7 @@ describe('cordis_mount', () => { }) it('normalizes a self-made tool\'s result into the host realm, so the session log accepts it', async () => { - // Normalize vm-realm results into host JSON before session validation. + // VM-realm objects fail the session prototype-identity check; normalize them into host JSON. const ctx = await setup() await call(ctx, 'cordis_mount', { code: REVERSE_TOOL_CODE }) const reversed = await call(ctx, 'reverse_text', { text: 'harness' }) @@ -89,9 +89,8 @@ describe('cordis_mount', () => { ['object-form blocks missing the type tag', 'return { content: [{ text: \'hi\' }] }', '{"content":[{"text":"hi"}]}'], ['undefined — a forgotten return', 'return undefined', 'undefined'], ])('rejects an execute return of %s as that one call\'s teaching error', async (_label, returnStatement, preview) => { - // The failure this prevents: the registry trusts the return shape (postExecute spreads - // result.content), so an unvalidated { content: 'ok' } would enter the session log as - // ['o','k'] and silently corrupt the next model request. + // The registry spreads result.content, so { content: 'ok' } would become ['o','k']; reject + // it as this call's error before it corrupts the next request. const ctx = await setup() await call(ctx, 'cordis_mount', { code: ` @@ -143,8 +142,8 @@ describe('cordis_mount', () => { }) it('accepts a JSON-Schema-style parameters wrapper and normalizes it to the DSL', async () => { - // The dialect models write by strong prior: the { type:'object', properties, required: […] - // } wrapper, `type: 'integer'`, and `required: false`. + // These common JSON-Schema spellings each have one DSL meaning, so normalize rather than + // consume another model turn with a rejection. const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` diff --git a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts index 35e5f118fd..c27d34d4c3 100644 --- a/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts +++ b/packages/cordis/tool-cordis/tests/sandbox-context.spec.ts @@ -2,13 +2,10 @@ import { describe, expect, it } from 'vitest' import { call, setup, text } from './helpers.ts' /** - * The sandbox context façade is a whitelist, not a pass-through proxy: mount - * code reaches only the registration/eventing verbs, the timer helpers, a - * guarded `tools`, and its injected services. Every framework-plumbing member - * that could hand back an UNGUARDED context — through which a plugin could - * `ctx..tools.register({…})` to bypass the marker check and host-realm - * normalization — is denied. These are the regression guards for that escape - * class (the review finding on the original pass-through proxy). + * The sandbox context façade is a whitelist, not a pass-through proxy. Mounted code reaches only + * registration/eventing verbs, timer helpers, guarded tools, and injected services. Framework + * members that expose an unguarded context are denied because they could bypass marker checks and + * host-realm normalization; these tests pin that escape class. */ /** Mount a plugin whose `apply` touches one framework member, and report the error text. */ @@ -77,7 +74,8 @@ describe('sandbox context façade — escape surface is closed', () => { it('denies a service whose method returns a Context (the .ctx escape), registering nothing', async () => { // A cordis Service instance carries `.ctx` (a real Context), so - // `ctx.systemPrompt.ctx.root.tools.register(…)` would be a fresh unguarded handle. + // `ctx.systemPrompt.ctx.root.tools.register(…)` would escape the façade; service-return + // guards reject that Context before the registration lands. const ctx = await setup() const result = await call(ctx, 'cordis_mount', { code: ` @@ -189,9 +187,8 @@ describe('sandbox context façade — inject gate on services', () => { }) it('a cross-mount consumer must declare the provider — the undeclared path is refused, not left as a zombie tool', async () => { - // The finding's scenario: a consumer registers a tool built on a provider's service WITHOUT - // declaring inject. cordis would then never park the consumer when the provider unmounts, - // leaving a tool that fails only at execution. + // Without declared inject, Cordis cannot park the consumer when its provider unmounts. The + // façade refuses access up front instead of leaving a zombie tool. const ctx = await setup() await call(ctx, 'cordis_mount', { code: 'return { name: \'greeter-provider\', apply(ctx) { ctx.provide(\'greeter\', { greet: (n) => \'hi \' + n }) } }', diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index bb40aaa62f..ba547ac6df 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -2,7 +2,7 @@ The **default executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs, including the local skill provider, and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends. -This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle. +Read this package for the whole plugin tree and its composition order. ## The tree it loads diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index f7dacb62ec..14ffbd6133 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -1,5 +1,9 @@ /** - * The default executor-less, UI-less agent spine as one bundle plugin. + * Default executor-less, UI-less agent spine. It bundles the common services, + * concrete loop, local skill provider, and model-facing bash/skill consumers; + * deployments still choose the LLM adapter, bash executor, and presentation. + * The plugin intentionally exposes named exports only because Loader default + * unwrapping would discard its `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-agent-core */ @@ -32,10 +36,12 @@ export interface SkillConfig { /** * 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), + * agent loop (an app that pre-creates no agents, like the ACP bridge, omits it), * `persona` and `toolOrder` to the system-prompt plugin (the deployment's persona section and * the explicit model-facing tool order), the `tools` object to the tool registry (its * presentation `mode`), and `skills` to the skill registry/local provider/tool consumer. + * The schema intersects the owners' schemas, which supply defaults for every + * optional input and keep validation from drifting. */ export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 8c29036af4..0bf0660364 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -187,7 +187,8 @@ describe('dsh-agent-core bundle', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { - // A default export would make Loader discard this namespace's plugin metadata. + // A default export would make `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard. expect('default' in agentCore).toBe(false) expect(typeof agentCore.apply).toBe('function') diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6315aebd7d..cd6a694cc4 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,12 +10,14 @@ This is the only package in the harness that contains concrete loop logic. Every Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md). -- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id. +Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach. + +- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create(options)` creates on the supplied session id and returns an owned [`AgentHandle`](../agent/README.md). -- `ctx.agents.resume(options)` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues the stored history, and returns the same handle shape. +- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication. +- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 521ccfedea..a2288c65b7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -48,7 +48,8 @@ export interface PreparedReactLoopAgent { } /** - * Construct an unpublished concrete agent with instance-bound lifecycle controls. + * Construct an unpublished concrete agent with instance-bound lifecycle + * controls. Only those paired controls can publish or start this instance. * @param ctx - the agent-loop service context used for driving and events. * @param id - the concrete agent identity. * @param options - loop options for the agent. @@ -253,7 +254,8 @@ export class ReactLoopAgent implements Agent { // Decide the durability checkpoint from the log: an accepted one-shot // turn must be flushed even when its message append was the failing step. const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn) - // Track the asynchronous checkpoint so disposal drains it; contain errors. + // Keep inject() synchronous: report checkpoint failures live instead of + // rejecting the caller, and track the task so disposal still drains it. if (turnRecorded) { // Through the store's flush (the carrier owner), never a raw parallel. const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => { @@ -290,7 +292,11 @@ export class ReactLoopAgent implements Agent { this.currentAbort?.abort(reason ?? 'cancelled') } - /** Resolve at idle, or after driver exit when disposed. */ + /** + * Resolve immediately when idle with no queued work, on the next quiescent + * idle transition otherwise, or after driver exit when already disposed. + * This observes quiescence; it does not own teardown. + */ whenIdle(): Promise { if (this._status === 'disposed') return this.done if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve() @@ -330,7 +336,7 @@ export class ReactLoopAgent implements Agent { isCancelled: () => this.cancelRequested, cancelReason: () => this.cancelReason, clearCancel: () => { this.cancelRequested = false }, - // Pre-step cancellation re-parks without a status transition. + // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, }) } @@ -370,7 +376,8 @@ export class ReactLoopAgent implements Agent { // cleanup. The normal loop contains turn failures itself; allSettled is the // final lifecycle backstop for anything outside those boundaries. await Promise.allSettled([this.done]) - // Repeat because settled flushes retire in adjacent promise reactions. + // Repeat because settled flushes retire in adjacent promise reactions; + // allSettled keeps reporting failures from skipping ownership teardown. while (this.pendingIdleFlushes.size > 0) { await Promise.allSettled([...this.pendingIdleFlushes]) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 53f07ba426..6cd66f622c 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -73,7 +73,11 @@ function signalAbortError(id: AgentId, signal: AbortSignal): Error { return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } -/** Caller-owned create/resume transaction through publication and teardown. */ +/** + * Caller-owned create/resume transaction through rollback-covered publication + * and quiescent teardown. Resources remain private until the final registry + * entry arbitrates identity. + */ class AgentCreationTransaction { private active = true private failure: Error | undefined diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index be7db9852b..18eca40cc1 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -1,4 +1,9 @@ -/** Agent loop driver with turn-level error containment. @module dsh-agent-loop/loop */ +/** + * Drives one agent across queued durable turns. Turn failures are contained so + * later work can run; the session log, not this driver, owns conversation state. + * See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md. + * @module dsh-agent-loop/loop + */ import type { Context } from 'cordis' import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm' @@ -78,7 +83,7 @@ export interface LoopHandle { cancelReason(): string /** Clear the cancel marker (called once per iteration after the turn returns). */ clearCancel(): void - /** Settle idle waiters when a cancelled turn is skipped without a status transition. */ + /** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */ settleIdle(): void } @@ -267,7 +272,9 @@ async function runTurn( break } - // Compose, detach, and freeze the per-instance prefix before pressure checks. + // Compose the request-only prefix once per loop instance before pressure + // checks. It precedes all derived history and is recorded only in the + // request header, not as session history. if (transmission.sessionPrefix === undefined) { const emptyPrefix: Message[] = deepFreeze([]) const composed = await events.waterfall( @@ -294,7 +301,8 @@ async function runTurn( break } - // Snapshot the exact log prefix before step/start: the reconstruction boundary. + // Snapshot the exact log prefix before step/start: the reconstruction + // boundary. Appends after this synchronous snapshot join the next request. const boundaryMessages = session.deriveMessages() session.append('step/start', { turn, step }) @@ -444,13 +452,12 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole return messages.length > 0 } -/** One step: build the request from the boundary snapshot + the step's - * header → compose the session prefix if this instance has none yet → log - * the header event the request owes → stream model → record → execute - * tools. The caller assembles the - * system prompt, fires the `agent/pre-step` seam, snapshots the derivation, - * and opens the step BEFORE calling this, so `boundaryMessages` is exactly - * the surface prefix at step/start and already reflects any compaction. */ +/** + * Run one committed step: transform call config, log the request header, build + * the request from the cached prefix plus the step-boundary snapshot, stream and + * record the response, then execute tools. The caller has already assembled the + * prompt, run `agent/pre-step`, snapshotted history, and opened the step. + */ async function runStep( ctx: Context, events: AgentEventDispatch, @@ -560,7 +567,8 @@ async function runStep( } catch { parsedArguments = call.arguments } - // TODO(pre-tool-input-rewrite): A rewrite must keep logged history and live presentation aligned. + // TODO(pre-tool-input-rewrite): Keep logged history and live presentation aligned; + // see docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md. const result = await ctx.tools.execute({ callId: call.id, name: call.name, diff --git a/packages/core/agent-loop/src/request-log.ts b/packages/core/agent-loop/src/request-log.ts index bab2149ab4..c7314c2409 100644 --- a/packages/core/agent-loop/src/request-log.ts +++ b/packages/core/agent-loop/src/request-log.ts @@ -1,7 +1,7 @@ /** - * Per-loop-instance transmission bookkeeping for the reconstructability contract: which header - * event to append before a request so the session log always explains the request (the - * reconstructability RFC). + * Per-loop-instance request-header bookkeeping for reconstructability. The + * comparison baseline is the header folded from the session log, so a fresh + * loop instance needs no special resume or fork state. * @module dsh-agent-loop/request-log */ @@ -32,8 +32,10 @@ export function createTransmissionLog(): TransmissionLog { } /** - * Append whatever header event this request owes the log, so folding the log reproduces the - * header the request was built under. Exactly one of four things happens. + * Append whatever header event makes the log reproduce this request's header. + * The first request from an instance always records a full `initial` or `resume` + * snapshot. Later requests record nothing when unchanged, a round-tripping + * delta when expressible, or a full `fallback` snapshot otherwise. * * @param session - the session whose log explains the request. * @param state - this loop instance's bookkeeping (mutated on first log). diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 73c9b88c43..581b6fa207 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -167,7 +167,8 @@ describe('ReactLoopAgent', () => { let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) - // A post-turn-start append failure still closes and checkpoints the turn. + // Invalid injected content throws after turn/start. `finally` must still append turn/end and + // flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint. expect(() => { agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) }).toThrow(/non-JSON-serializable/) @@ -249,26 +250,20 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // Create a bare ReactLoopAgent and start it through the package-internal - // test seam. Then call its disposer twice — the second call hits the - // early-return branch. + // The internal start seam exposes one idle driver's disposer for repeated invocation. const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) const { agent } = prepared - // Start the loop to get the disposer; the agent waits for messages - // (idle, never-resolving cancel), so it will stay idle. prepared.markPublished() const dispose = prepared.startDriver() - // First dispose const firstDisposal = dispose() expect(agent.status).toBe('disposed') await firstDisposal - // Second dispose — idempotent, no throw await expect(dispose()).resolves.toBeUndefined() expect(agent.status).toBe('disposed') }) @@ -363,7 +358,8 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { - // The disposed waiter must chain the driver exit, not resolve eagerly. + // Queue the internal waiter while running, then dispose the bare driver. Its disposed branch + // must chain the loop's `done` promise rather than resolve before exit. const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(SessionStore) @@ -389,7 +385,8 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { - // Fiber disposal must settle the agent-owned waiter. + // The waiter is agent-owned state, not an effect-scoped listener that owner disposal would + // remove before the disposed transition. Fiber teardown must still settle it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: ReactLoopAgent @@ -407,7 +404,8 @@ describe('ReactLoopAgent', () => { }) it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => { - // Disposed status precedes driver exit; whenIdle must await both. + // Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it + // resolves only after true loop exit. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: ReactLoopAgent diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index bc066e3433..cad830e827 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -2,7 +2,8 @@ * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it * clears queued + steering work, aborts an in-flight step, and drops a turn about to start — * whereas a bare step abort (the loop's private `AbortController`) kills only the current step - * and leaves the queue intact. + * and leaves the queue intact. The suite covers every landing window plus marker + * reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ @@ -91,8 +92,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // Queue work, then register a whenIdle() waiter while in the pre-step window (status idle, - // hasQueued true) — it does not take the fast path. + // This waiter cannot rely on a running→idle transition because cancellation + // drops the turn before it runs; the skip path must settle it directly. send(agent, 'q') const idle = agent.whenIdle() agent.cancel('pre-step') @@ -228,8 +229,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // The first composition is interrupted mid-waterfall and — like an abort-aware listener - // bailing on a firing signal — contributes nothing. + // The interrupted first composition must not cache its degraded empty value; + // the next prompt recomposes and logs/sends the fresh prefix. const opener: Message = { role: 'user', content: [{ type: 'text', text: 'fresh opener' }] } let compositions = 0 ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => { @@ -258,8 +259,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // A turn/start listener fires right after turn/start is appended, before any - // AbortController is installed for the step. + // A turn/start listener fires before a step controller exists, so the + // turn-scoped marker—not step abort—must drop the pending step. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('session/event', (session, event) => { @@ -388,8 +389,8 @@ describe('Agent.cancel()', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running listener can cancel - // in the gap between the loop's pre-step check and runTurn. + // `agent/status` is synchronous, so cancellation can land after the first + // pre-step check; the second check must drop the now-empty turn. let streamed = false ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) const dispose = ctx.on('agent/status', (subject, status) => { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index a9a9e56428..8a5d9ce264 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -101,7 +101,8 @@ describe('config-driven session id', () => { await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() - // Run 2: a CONFIG agent with resumeSessionId continues that session. + // Resume waits for the injected persistence service, so poll until the + // config-created agent appears with its stored history. const ctx2 = new Context() await ctx2.plugin(LlmService) await ctx2.plugin(SessionStore) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts similarity index 97% rename from packages/core/agent-loop/tests/review-fixes.spec.ts rename to packages/core/agent-loop/tests/contract-regressions.spec.ts index b2e83920d8..8130d4b893 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -39,7 +39,7 @@ function send(agent: ReactLoopAgent, text: string) { agent.send([{ type: 'text', text }]) } -describe('HIGH: session log records what agent/step-result actually produced', () => { +describe('session log records what agent/step-result actually produced', () => { it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => { const adapter = new MockAdapter([textResponse('original'), textResponse('done')]) const ctx = await harness(adapter) @@ -89,7 +89,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) }) -describe('HIGH: abort during tool execution ends the turn', () => { +describe('abort during tool execution ends the turn', () => { it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step @@ -141,7 +141,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) }) -describe('HIGH: steering from late extension points is never stranded', () => { +describe('steering from late extension points is never stranded', () => { it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -247,7 +247,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { }) }) -describe('HIGH: plugin exceptions are contained', () => { +describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -302,7 +302,7 @@ describe('HIGH: plugin exceptions are contained', () => { }) }) -describe('MEDIUM: disposed status is part of the agent/status contract', () => { +describe('disposed status is part of the agent/status contract', () => { it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -349,7 +349,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { }) }) -describe('MEDIUM: misc registry and config fixes', () => { +describe('adapter registration, routing, and accepted-input ownership', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -508,7 +508,7 @@ describe('MEDIUM: misc registry and config fixes', () => { }) }) -describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => { +describe('turn numbering continues across seeded sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) @@ -546,7 +546,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () }) }) -describe('LOW: discriminated SessionEvent narrows without casts', () => { +describe('discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { const session = new Session(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { @@ -564,7 +564,7 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => { }) }) -describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => { +describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { // A finish-error chunk must not produce a completed assistant turn. const errorStream: StreamChunk[] = [ @@ -587,7 +587,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete // a standalone error event. const turnEnd = events.find(event => event.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }) - // Crucially: no assistant/message was logged for the failed step. + // A failed step must not synthesize an assistant message. expect(events.some(event => event.type === 'assistant/message')).toBe(false) }) @@ -1104,7 +1104,8 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream describe('disposal and cancellation during pre-step assembly', () => { it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => { - // Release assembly only after disposal has marked the agent disposed. + // Start disposal, then release assembly. Do not await disposal first: it + // waits for the blocked driver to exit. const adapter = new MockAdapter(['hang']) let releaseAssemble!: () => void const blocked = new Promise(r => void (releaseAssemble = r)) @@ -1137,28 +1138,22 @@ describe('disposal and cancellation during pre-step assembly', () => { // Give the loop time to enter the step and reach assemble(). await new Promise(r => setTimeout(r, 50)) - // Start disposal — stop() sets status=disposed synchronously, then the - // disposer's await agent.done hangs because the loop is blocked in the - // waterfall. Do NOT await yet; release the blocker first. + // Release assembly before awaiting disposal because disposal joins the blocked driver. const disposalDone = fiber.dispose() - // Now release the blocked waterfall — the loop unblocks, checks - // isDisposed(), and exits, which resolves agent.done and disposalDone. releaseAssemble() await disposalDone await agent.done unlisten() + // Turn boundaries are durable rows; there is no `agent/*` mirror to assert. const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1) const turnEnd = e.findLast(x => x.type === 'turn/end') expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' }) - // No step was opened, no LLM call was made. expect(e.some(x => x.type === 'step/start')).toBe(false) expect(e.some(x => x.type === 'assistant/chunk')).toBe(false) - // The durable turn/end record is the authoritative turn-boundary signal - // (turn boundaries have no agent/* mirror), so this asserts on the log. }) it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => { @@ -1215,7 +1210,8 @@ describe('disposal and cancellation during pre-step assembly', () => { }) it('disposal during agent/pre-step seam ends the turn disposed', { timeout: 15000 }, async () => { - // Release pre-step only after disposal has marked the agent disposed. + // Start disposal, then release pre-step; awaiting disposal first would + // deadlock on the blocked driver. const adapter = new MockAdapter(['hang']) let releasePreStep!: () => void const blocker = new Promise(r => void (releasePreStep = r)) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 23e5670f85..5deee8e159 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -225,9 +225,7 @@ describe('disposed vs aborted branching', () => { await fiber.dispose() // dispose during hang await agent.done - // The review-fixes test for 'HIGH: disposed status' already covers - // this assertion path. The reason is 'disposed' because isDisposed() is - // checked before the abort signal check in the error path. + // Disposal wins abort classification because the error path checks it first. expect(reasons).toContainEqual({ kind: 'disposed' }) }) }) diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index f893af55db..4bea62abe2 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -64,16 +64,12 @@ describe('Inbox', () => { void inbox.waitForQueued(new Promise(() => {})) // first call, never resolved void inbox.waitForQueued(p1) // second call overwrites wakeup - // Cancel p1 (the latest waiter's cancel) — the wakeup was overwritten - // to p1's resolve, so canceling p1 triggers the finally block which - // clears the wakeup if it matches. + // Cancelling the latest waiter clears the shared callback; enqueue must neither + // wake the stale waiter nor fail on the cleared callback. r1() await p1 - // Now enqueue: the first waiter's wakeup (which was overwritten) won't fire, and the second - // waiter's wakeup was cleared by cancel. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } }) - // The overwrite path + finally cleanup are exercised }) it('clears wakeup in finally handler when enqueue resolves', async () => { @@ -87,23 +83,17 @@ describe('Inbox', () => { }) it('finally handler does not clear wakeup when a different waiter overwrote it', async () => { - // First waiter's cancel resolves AFTER a second waiter overwrote wakeup. - // First waiter's finally sees wakeup !== its resolve → does not clear. + // A stale waiter's finally must not clear the replacement waiter. const inbox = new Inbox() const { promise: c1, resolve: r1 } = resolverPair() void inbox.waitForQueued(c1) // wakeup = resolve1, c1.then(resolve1) void inbox.waitForQueued(new Promise(() => {})) // wakeup = resolve2, cancel never resolves - // Resolve c1 (the first cancel). c1.then(resolve1) fires → resolve1() called - // → waiter1's promise resolves → finally: wakeup === resolve1? NO (it's resolve2) - // → wakeup is NOT cleared. r1() await c1 - // Now enqueue: wakeup() calls resolve2 → waiter2 resolves - // But waiter2's cancel never resolves — that's fine, enqueue resolves it. + // The replacement remains registered and is resolved by enqueue. inbox.enqueue({ content: [{ type: 'text', text: 'hey' }], source: { kind: 'user' } }) - // No need to await anything further — enqueue is synchronous wakeup }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index ba6a3d8e79..4b37210993 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -117,9 +117,8 @@ describe('agent/prompt-submit', () => { }) it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => { - // The merge of the interception seams with master's compaction seam pins one ordering: - // `agent/prompt-submit` runs (rewriting the prompt and injecting context) before the step - // loop, and `agent/pre-step` fires inside the step before the single deriveMessages(). + // Prompt rewrites and injected context land before `agent/pre-step`, so a + // compaction listener measures the current surface before the single derive. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -184,7 +183,8 @@ describe('agent/prompt-submit', () => { }) it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => { - // Two prompts queued into one turn: block "secret", allow "safe". + // Blocking one prompt in a mixed batch must persist its reason even though + // the allowed prompt keeps the turn from ending rejected. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -508,14 +508,13 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { await waitForIdle(ctx, agent) const log = events(agent) - // same turn, two steps + // The continuation stays in the turn, is logged with provenance before step 2, + // and reaches that step's request. expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(log.filter(e => e.type === 'step/start')).toHaveLength(2) - // the reason was recorded as steering BEFORE step 2, with its plugin source const steering = log.find(e => e.type === 'steering/message') expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }]) expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' }) - // and reached the next request expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal') }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fccd93462c..fb686928b1 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -518,8 +518,8 @@ describe('agent loop', () => { }) it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => { - // A listener appending a surface node in pre-step lands it before step/start in the log — - // proving the seam fires outside the step. + // The append lands before step/start, yet derive happens afterwards and the + // same step's request must include it. const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -552,9 +552,8 @@ describe('agent loop', () => { }) it('a throwing agent/pre-step listener ends the turn (error), not the loop', async () => { - // The seam fires before step/start, so a throw escapes to runTurn's outer catch: the - // not-yet-open step closes as a no-op, the failure surfaces via agent/error, and the turn - // ends `error` (recorded on the durable turn/end). + // Before step/start, a pre-step throw reaches the turn catch: no step needs + // closing, the turn records error, and the loop remains available. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -621,7 +620,7 @@ describe('agent loop', () => { expect(adapter.requests).toHaveLength(1) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - // and the reason is recorded in the log's turn/end event + // Assert the durable row, not only the live listener. const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end') expect(turnEnd!.data.reason).toEqual({ kind: 'max-tokens' }) }) @@ -710,8 +709,8 @@ describe('agent loop', () => { expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }]) expect(reasons).toEqual([{ kind: 'max-tokens' }]) - // No-data-loss: a max-tokens step whose only content was a dropped tool call has EMPTY - // assistant content, but its usage must still be represented. + // Empty content still needs an assistant/message to carry usage; derivation + // skips that host so it does not create a spurious assistant turn. const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message') expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({ turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 }, diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 4b75f87966..dbc43ad985 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -1,5 +1,7 @@ /** - * Property-based tests for the agent loop's inbox/turn scheduling (the property-testing RFC). + * Deterministic property tests for inbox scheduling: every sent message logs + * once, turn numbers increase, and status follows idle→running→idle/disposed. + * Schedules advance on status events rather than wall-clock sleeps. */ import { describe, expect, it } from 'vitest' @@ -139,8 +141,8 @@ describe('agent loop scheduling properties', () => { const ctx = await harness() try { const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) - // Capture an idle waiter before EACH send; the last one is guaranteed to resolve - // because the final send always triggers (or joins) a turn that ends idle. + // Capture before each send; the last waiter covers the final turn, and + // awaiting an already-settled earlier waiter is harmless. let lastIdle: Promise | undefined for (const step of steps) { const idle = nextIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 49805b043b..9b8513d16f 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -14,7 +14,8 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' * `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's * `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is * the production observable for cache behavior (the reconstructability RFC's measurement - * layer: prefix stability is corollary #1). + * layer: prefix stability is corollary #1). Mocks establish append-extension; + * this key-gated test establishes a real provider cache hit. */ // Long enough that the shared request prefix comfortably spans the provider's diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 629cc24448..c4fe471bd0 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -2,7 +2,8 @@ * Loop-level reconstructability: every request the loop sends is a pure function of the * session log — messages are the derivation at the step/start boundary, the header is the fold * of request/header* events — and every request is an append-extension of its predecessor - * unless a logged event (compaction replace, header change) explains the difference. + * unless a logged event (compaction replace, header change) explains the difference. Mock-adapter + * requests are the observable, and the final offline rebuild states the full contract end to end. */ import { describe, expect, it } from 'vitest' diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index cd9d327816..3747824231 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -460,7 +460,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => { - // Lifecycle 1: run a turn, then inject context while idle. + // Idle injection creates and flushes a one-shot turn. No explicit flush or + // clean disposal follows, so disk presence proves its own checkpoint ran. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent @@ -482,7 +483,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) it('an idle inject() survives persist + resume (turn-enclosed, not dropped as crash tail)', async () => { - // Lifecycle 1: run a turn, then inject context while idle. + // Turn enclosure keeps idle context out of crash-tail repair, so it must + // survive persistence and resume. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 421893d13b..13e731b7b8 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -2,7 +2,8 @@ * Loop-level tool-order determinism: the request/header event — and therefore the frozen * request the adapter receives — carries the assembly's canonical tool order (system-prompt's * `toolOrder` config, or lexicographic name order), regardless of the order tool plugins - * happened to register in. + * happened to register in. Registration order is a concurrent loading artifact + * and must not leak downstream. */ import { describe, expect, it } from 'vitest' diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index eb8257f72d..9d024d36be 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -1,4 +1,9 @@ -/** Agent-scoped subject dispatch and prompt assembly context helpers. @module @deepseek-ai/dsh-agent/dispatch */ +/** + * Agent-scoped dispatch and prompt assembly helpers. Ordinary events use the + * fused dispatcher so subject and scope key cannot diverge; registry lifecycle + * code instead captures one stable carrier for both edges. + * @module @deepseek-ai/dsh-agent/dispatch + */ import type { Context, Events } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -107,7 +112,8 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { } /** - * Build the prompt assembly context with agent and scope set together. + * Build the prompt assembly context with agent and scope set together, so + * agent-scoped prompt and tool contributions cannot be silently omitted. * @param agent - the agent the assembly is for. * @returns the context to pass to `assemble()`. */ diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 172a8903f2..16fd272032 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -65,14 +65,15 @@ export interface ResumeAgentOptions { readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ readonly signal?: AbortSignal - /** Compose the unpublished scoped context after persistence load. */ + /** Compose after persistence load under the same unpublished rollback contract as create. */ readonly setup?: (agentCtx: Context) => Promise | void } /** * Holder-owned agent capability. Disposal stops and drains the loop and idle * flushes before unregistering the agent, detaching its session, and unwinding - * its scoped context. Registry observers receive only the bare {@link Agent}. + * its scoped context. Provider unload reaches the same quiescence boundary; + * registry observers receive only the bare {@link Agent}. */ export interface AgentHandle { agent: Agent @@ -87,8 +88,9 @@ export interface AgentHandle { */ export interface AgentFactory { /** - * Create, compose, publish, announce, and start an agent under the caller's - * ownership. Rollback pairs any creation announcement that began. + * Create and compose under caller ownership, publish and announce session then + * agent, emit session-start, and start the driver. Rollback pairs any creation + * announcement that began. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. @@ -211,7 +213,8 @@ export class AgentRegistry extends Service { /** * Insert an unpublished agent for an ordered factory transaction. * @param agent - the prepared, unpublished agent. - * @returns an idempotent detach closure; during creation dispatch it defers. + * @returns an idempotent closure that removes this exact entry and emits the + * paired disposal edge; detachment during creation dispatch is deferred. */ enter(agent: Agent): () => void { const id = agent.id diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3341e3499b..9e10e09be1 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -26,7 +26,7 @@ import type { Session } from '@deepseek-ai/dsh-session' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { - /** Agent for this assembly; absent on unscoped diagnostic assemblies. */ + /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent } } @@ -37,7 +37,7 @@ export interface AgentOptions { model?: string } -/** Message options; an omitted source resolves to `{ kind: 'user' }`. */ +/** Message options; an omitted source resolves to `{ kind: 'user' }`, so plugins must label their own content. */ export interface SendOptions { source?: MessageSource } @@ -86,10 +86,13 @@ export interface Agent { readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - /** Agent-scoped context; its contributions are agent-local and unwind on disposal. */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context - /** Queue detached, frozen lossless-JSON input; starts a turn when idle. */ + /** + * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Invalid input throws synchronously before notification or enqueue. + */ send(content: ContentBlock[], options?: SendOptions): void /** @@ -106,7 +109,10 @@ export interface Agent { */ inject(content: ContentBlock[], options?: SendOptions): void - /** Clear queued and steering work and abort the active step; idle cancellation is a no-op. */ + /** + * Clear queued and steering work, including work waiting to start, and abort + * the active step. Idle cancellation is a no-op and does not arm a later cancel. + */ cancel(reason?: string): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ @@ -118,8 +124,11 @@ declare module 'cordis' { interface Events { // ---- lifecycle (emit) ---- /** - * A fully configured agent and its session were published. Synchronous - * listener failure vetoes publication; asynchronous failure is reported. + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. * @param agent - the newly registered agent with its live session and completed setup. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit @@ -134,7 +143,8 @@ declare module 'cordis' { */ 'agent/disposed'(this: Scoped, agent: Agent): void /** - * Agent status changed (`idle` ⇄ `running`, or → `disposed`). + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. * @param agent - the agent whose status flipped. * @param status - the status just entered (the transition's destination). * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. @@ -142,7 +152,8 @@ declare module 'cordis' { */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void /** - * Detached, frozen content entered the agent's inbox. + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. * @param agent - the agent whose inbox received the message. * @param content - the accepted content blocks retained by the inbox. * @param info - the accepted source plus whether it entered as steering. diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index 09269f8618..2761351d10 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -150,7 +150,7 @@ describe('verify-export-jsdoc export forms', () => { ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)]) }) - it('does not treat a never-exported sibling declarator as surface (review round 2)', () => { + it('does not treat a never-exported sibling declarator as surface', () => { // `export { publicValue }` resolves to the whole variable statement; only // the named declarator is surface — the gate must not demand JSDoc for // the private sibling sharing the statement. @@ -159,7 +159,7 @@ describe('verify-export-jsdoc export forms', () => { ))).toEqual([]) }) - it('unions declarators across multiple export lists over one statement (review round 2)', () => { + it('unions declarators across multiple export lists over one statement', () => { // Two lists each name one declarator of the same undocumented statement: // both are surface (deduplicating on first resolution would drop `b`), // while the never-exported `c` stays out. @@ -172,7 +172,7 @@ describe('verify-export-jsdoc export forms', () => { ]) }) - it('scopes a default-export identifier to its own declarator (review round 2)', () => { + it('scopes a default-export identifier to its own declarator', () => { // `export default` of an identifier reaches the statement through the // same name lookup as an export list; the sibling stays private. expect(collectExportJsdocViolations(make( @@ -315,7 +315,7 @@ export namespace Loose { }) }) -describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { +describe('verify-export-jsdoc fail-closed forms', () => { it('checks the function contract on a non-identifier default export', () => { expect(collectExportJsdocViolations(make( '/** Doubles. */\nexport default (x: number): number => x * 2\n', @@ -408,7 +408,7 @@ describe('verify-export-jsdoc fail-closed forms (review round 1)', () => { }) }) -describe('verify-export-jsdoc heritage refinement (review round 1)', () => { +describe('verify-export-jsdoc heritage refinement', () => { it('requires @param for parameters the base member never names', () => { const violations = collectExportJsdocViolations(make(` /** Seam. */ diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index 76b8236885..f09844e9ab 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -73,11 +73,11 @@ export function scopeOf(ctx: Context): ScopeKey | undefined { } /** - * Build an opaque receiver that preserves the base filter, admits untagged - * listeners globally, and admits tagged listeners only for a matching key. + * Build an opaque receiver that preserves the base filter, admits untagged + * listeners globally, and admits tagged listeners only for a matching key. * @param base - subject or service whose existing Cordis filter is preserved. * @param key - routed scope identity, or `undefined` for an unscoped subject. - * @returns a carrier whose subject remains available only through event arguments. + * @returns a carrier whose subject remains available only through event arguments. */ export function scopeTarget(base: T, key: ScopeKey | undefined): Scoped { const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter] diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 2ebbd0390c..82cdbb19ba 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -8,8 +8,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, options?)` validates and detaches durable seed/header data, publishes the session, and binds it to the calling fiber. -- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. It rejects unpublished, detached, or stale objects. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt` and `seedLength`. +- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -18,9 +18,9 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall Use the split lifecycle only when teardown must be ordered with another resource: -- `prepare(id?, options?)` constructs without publication. -- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. -- `announce(session)` emits the single creation edge. Detach during that dispatch is deferred and later emits the paired disposal edge. +- `prepare(id?, options?)` validates and constructs without publication. +- `enter(session)` performs the collision check, publishes without announcing, and returns an entry-bound idempotent detach. Concurrent same-id preparations are allowed, but only one entry succeeds; a stale detach cannot remove its replacement. +- `announce(session)` emits the single creation edge and rejects repeat or reentrant announcements. Detach during that dispatch is deferred and later emits the paired disposal edge; an unannounced entry emits neither lifecycle edge. `dsh-agent-loop` uses this split so final loop flush precedes session detach; see the [ownership RFC](../../../docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md). @@ -32,10 +32,10 @@ The store pairs announced creation with disposal, publishes post-commit append n Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?)` snapshots and freezes durable data, commits synchronously, then notifies observers with failure containment. Reentrant attached-session appends reject. -- `session.deriveMessages()` incrementally projects the derived surface and returns a fresh array over frozen messages. +- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. +- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback. - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. -- `session.surface` lazily folds new `surfaceOp` markers; `replaceGeneration` changes on rewrites. +- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite or invalidation. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5b01c38fe6..d233e711eb 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -34,8 +34,10 @@ declare module 'cordis' { interface Events { /** - * Emitted after session publication. A synchronous throw vetoes and rolls + * Creation announcement during session publication. A synchronous throw vetoes and rolls * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners * receive only sessions entered through that agent's context. * @param session - the session just entered and announced. @@ -44,14 +46,17 @@ declare module 'cordis' { 'session/created'(this: Scoped, session: Session): void /** * Emitted once when an announced session leaves the store, including - * publication rollback. Listener failures are contained. + * publication rollback, but never for an entry whose creation announcement + * did not begin. Listener failures are logged and contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. * @param session - the session that is no longer live in the store. * @mode emit */ 'session/disposed'(this: Scoped, session: Session): void /** - * Post-commit append feed. Observer failures are logged and contained. + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners * receive only events from sessions entered through that agent's context. * @param session - the session whose log grew. @@ -60,7 +65,8 @@ declare module 'cordis' { */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void /** - * Awaited parallel durability checkpoint; dispatch through + * Awaited parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through * {@link SessionStore.flush}. Scope-filtered dispatch * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. * @param session - the session whose buffered events must reach durable storage. @@ -70,7 +76,11 @@ declare module 'cordis' { } } -/** Render injected context as a tagged synthetic user-role message. */ +/** + * Render injected context as tagged synthetic user-role content, keeping the + * canonical session vocabulary provider-neutral. Adapter-specific exceptions + * belong in the adapter. + */ function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] { const open = `<${tag} source=${JSON.stringify(source.kind)}>` const close = `` diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts index 28c75112b6..35874a49e5 100644 --- a/packages/core/session/src/json.ts +++ b/packages/core/session/src/json.ts @@ -12,9 +12,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } /** - * Validate and detach lossless JSON in one read per property. Accepts ordinary - * arrays, plain or null-prototype objects, and JSON scalars; rejects sparse, - * cyclic, exotic, negative-zero, and non-finite values. Getter throws propagate. + * Validate and detach lossless JSON in one read per property, so a stateful + * getter cannot change between validation and copying. Accepts ordinary arrays, + * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic, + * exotic, negative-zero, and non-finite values. Getter throws propagate. * * @param value - the candidate value to validate and detach. * @returns the detached snapshot, or `undefined` when the value is not diff --git a/packages/core/session/src/repair.ts b/packages/core/session/src/repair.ts index 3a6600ce6e..efbb3d2004 100644 --- a/packages/core/session/src/repair.ts +++ b/packages/core/session/src/repair.ts @@ -1,5 +1,7 @@ /** - * Crash-recovery repair for an interrupted session log. + * Crash-recovery repair for an interrupted session log. It preserves a fully + * written final turn and supplies the missing tool, step, and turn boundaries + * needed to resume with a provider-valid transcript. * @module @deepseek-ai/dsh-session/repair */ @@ -7,8 +9,10 @@ import type { CallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from './types.ts' /** - * Return deterministic synthetic events that close an open tail turn or step. - * Sequences continue the log and timestamps reuse the last real event. + * Return deterministic synthetic events that close an open tail turn. Unmatched + * calls receive error results first, followed by an open `step/end` and an + * interrupted `turn/end`; sequences continue the log and timestamps reuse the + * last real event. A balanced or empty log returns no events. * * @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail). * @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced. @@ -16,8 +20,8 @@ import type { SessionEvent } from './types.ts' export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] { let openTurn: number | null = null let openStep: number | null = null - // Track tool calls vs. their results WITHIN the currently-open turn only: a call is "pending" - // until its matching tool/result arrives. + // Reset at each turn boundary so earlier calls cannot leak into tail repair. + // Assistant blocks register calls; later tool/call events add provenance seqs. const pendingCalls = new Map() for (const event of events) { switch (event.type) { @@ -46,8 +50,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session } break case 'tool/call': - // Capture the tool/call event seq for surface provenance on the synthesized - // tool/result. + // Add the tool/call seq used as provenance on a synthetic result. { const entry = pendingCalls.get(event.data.callId) if (entry) { @@ -76,9 +79,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session const time = last.time const closers: SessionEvent[] = [] - // Synthesize an error tool/result for each tool-call left unanswered by the crash, so - // deriveMessages() yields a valid provider transcript on resume (a dangling assistant - // tool-call is rejected by every provider). + // Close calls before their step: providers reject dangling assistant calls, + // and Map insertion order preserves their transcript order. for (const [callId, { step, callSeq }] of pendingCalls) { closers.push({ type: 'tool/result', diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index cab0a2b73b..dd7b2a758d 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -1,6 +1,7 @@ /** - * Request-header reconstruction utilities: the pure fold/diff/apply trio over the - * `request/header` / `request/header-delta` session events. + * Request-header reconstruction utilities over `request/header` snapshots and + * `request/header-delta` events. Writers round-trip each proposed delta and use + * a full snapshot when the encoding cannot represent the change. * @module dsh-session/request-header */ @@ -128,8 +129,11 @@ function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | } /** - * Compute the `request/header-delta` payload between two canonical headers, or undefined when - * they are equal. + * Compute the `request/header-delta` payload between two canonical headers, or + * `undefined` when they are equal. The encoding cannot represent every change, + * including pure tool reordering, so callers must apply and compare the result + * before logging it and fall back to a full snapshot on mismatch. The session + * prefix is replaced whole; an empty array removes it. * * @param prev - the folded header the log currently implies. * @param next - the header the next request will actually use. diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 918a274ead..fcdb767b37 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -23,8 +23,9 @@ const SURFACE_EVENT_TYPES = new Set([ ]) /** - * Check only whether a type may enter the message surface. Use - * {@link isSurfaceEvent} when the mandatory `surfaceOp` must also be present. + * Check only whether a type may enter the message surface; it does not require `surfaceOp`. This + * detects eligible seed/load events missing their mandatory marker. Use {@link isSurfaceEvent} to + * narrow a fully formed event whose marker is present. * @param type - the event type string to test. * @returns true when the type is one of the five message-producing types. */ diff --git a/packages/core/session/src/tool-pairing.ts b/packages/core/session/src/tool-pairing.ts index 1333f08917..ce9dc639c7 100644 --- a/packages/core/session/src/tool-pairing.ts +++ b/packages/core/session/src/tool-pairing.ts @@ -1,6 +1,7 @@ /** - * Tool-pairing balance over a session's surface: is a given cut point in the surface a safe - * edge for a collapsed region (e.g. compaction)? + * Tool-pairing balance over a session surface. Compaction changes surface + * positions, so safe cuts are derived from tool-call/result content on the + * surface rather than step markers in the append-only log. * @module @deepseek-ai/dsh-session/tool-pairing */ @@ -27,10 +28,12 @@ function nodeDelta(event: SessionEvent): number { } /** - * Check that a surface cut does not split a tool call from its result. + * Check that a surface cut does not split a tool call from its result. A region + * is safe to collapse only when the cuts before its first node and after its + * last node both return `true`. * @param nodes - the surface linked list in head→tail order. * @param events - the session log each node's `seq` indexes into. - * @param beforeSeq - node immediately after the cut; absent from the surface means after-tail. + * @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail. * @returns whether every call before the cut has its result before the cut. * @throws if a result appears without a preceding open call. */ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 2acbc40780..42262712c8 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -39,8 +39,8 @@ export interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by this session — - * the seed boundary. + * How many leading events were inherited through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number } @@ -99,6 +99,7 @@ export interface TurnEndReasonMap { */ error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** * Policy blocked every prompt before the first step. The zero-step turn still @@ -106,8 +107,8 @@ export interface TurnEndReasonMap { */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a persistence backend - * later closed the orphaned (open) turn on reload so the log stays balanced. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } @@ -198,10 +199,10 @@ export interface ToolsDelta { } /** - * The session event vocabulary — the append-only source of truth for an agent's whole - * interaction history. The LLM message history is *derived* from this log; nothing else is - * authoritative. Replay = re-derive from the same events; trace/telemetry = subscribe to the - * log. + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. */ export interface SessionEventMap { /** @@ -224,8 +225,8 @@ export interface SessionEventMap { /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * A queued prompt an `agent/prompt-submit` listener VETOED — the durable record of a blocked - * prompt and why. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** @@ -262,24 +263,19 @@ export interface SessionEventMap { /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } /** - * The agent's whole todo list, carried as a full snapshot and replaced wholesale on each - * write — the current list is the most recent `todo/write` (last-write-wins on replay, no - * fold). Appended by an owning agent via `session.append('todo/write', { todos })`. + * Whole-list snapshot; the latest write wins on replay. It is log-only UI + * state and never enters derived model history. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, with the {@link - * RequestHeaderReason} it was recorded whole. + * Full {@link EpochHeader} for the next request, appended inside its step + * before dispatch. It is log-only and anchors subsequent deltas. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } /** - * Amendment to the folded {@link EpochHeader}: at least one of a {@link SystemDelta}, a - * {@link ToolsDelta}, a whole replacement {@link 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). + * Log-only amendment to the folded {@link EpochHeader}. System and tools use + * their delta codecs; config and prefix replace whole, with an empty prefix + * encoding removal. Writers verify round-trip equality or log a fallback snapshot. */ 'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] } } diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 85ab41e458..5d2047d9a5 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -1,4 +1,8 @@ -/** Derived-message cache behavior against a from-scratch replay oracle. */ +/** + * Derived-message cache contract against a scratch oracle: project new nodes + * once, rebuild on surface generation changes, return fresh arrays over shared + * frozen messages, and remain value-equal to replay at every step. + */ import { describe, expect, it } from 'vitest' import { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 89973c4d7a..d599536db5 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -13,8 +13,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' -// An appendable event: its type/data plus, for surface-eligible types, the explicit surface -// intent the generator declares (mirroring how a real caller passes it). +// Each arbitrary supplies its own surface intent; `build` must not synthesize +// one or the property would fail to exercise malformed fixture choices. type Appendable = { [T in SessionEventType]: { type: T; data: SessionEventMap[T]; intent?: SurfaceIntent } }[SessionEventType] diff --git a/packages/core/session/tests/repair.spec.ts b/packages/core/session/tests/repair.spec.ts index 2803b7ab3f..e0f6a5bb6e 100644 --- a/packages/core/session/tests/repair.spec.ts +++ b/packages/core/session/tests/repair.spec.ts @@ -155,9 +155,8 @@ describe('interruptedTurnClosers', () => { }) it('handles tool/call without a matching assistant/message entry gracefully', () => { - // A tool/call event exists in the log but no assistant/message registered the callId in - // pendingCalls (e.g., a plugin appended it directly, or the assistant/message from a prior - // step didn't have this call). + // A raw tool/call with no assistant-registered pending call has nothing to + // answer; repair still closes the step and turn without synthesizing a result. const events: SessionEvent[] = [ userTurnStart(1, 0), { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 4f20d8b102..2326e7b212 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -129,8 +129,8 @@ describe('Session', () => { it('rejects a surface-eligible append with no surfaceOp marker (runtime guard for the union-widening loophole)', () => { const session = new Session(SessionId('s5b')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // The typed overload makes surfaceOp mandatory only when the type argument is a SPECIFIC - // SurfaceEventType literal. + // A widened SessionEventType bypasses the overload's conditional requirement, + // so the runtime guard must still reject the missing surface marker. const widenedType = 'user/message' as SessionEventType expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) .toThrow(/surface-eligible and requires a surfaceOp marker/) @@ -657,8 +657,8 @@ describe('SessionStore', () => { }) it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => { - // prepare()/enter() are public cross-package primitives that a caller may separate with - // arbitrary work. + // A stale prepared object must not replace the live same-id entry; its later + // detach would otherwise remove the wrong session. const ctx = new Context() await ctx.plugin(SessionStore) const stale = ctx.sessions.prepare(SessionId('racy')) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 257828e466..f5b7acdae5 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -70,14 +70,11 @@ describe('SurfaceManager', () => { it('rebuild with replace operation splices out shadowed nodes', () => { const s = surfaceSession() - // seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end - // Surface nodes: seq 1 (user), seq 2 (assistant). - // Replace both with a compaction marker. Both 1 and 2 are valid surface seqs. + // Replace surface seqs 1 (user) and 2 (assistant) with the summary. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }, ) - // Now the surface should have just the compaction node. expect(s.surface.nodes.length).toBe(1) expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker expect(s.surface.nodes[0]!.prev).toBeNull() diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 7dfecf23ba..eb7b1a6203 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -4,7 +4,9 @@ import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts' import type { SessionEvent, SurfaceNode } from '../src/index.ts' /** - * Unit coverage for the tool-pairing balance check. + * Unit coverage for compaction-cut safety: a cut is balanced only when it + * separates no assistant tool call from its result. Non-step nodes are neutral, + * and replace operations prove surface order—not raw log order—is authoritative. */ const SURFACE = { surfaceOp: 'append' as const } @@ -165,8 +167,8 @@ describe('isToolPairingBalanced — multiple tool calls in one assistant message }) describe('isToolPairingBalanced — a mid-step injection context/message', () => { - // A background task-done inject() lands a context/message inside an open step, between the - // assistant (with a tool-call) and its tool/result. + // The injected context is pairing-neutral, but both adjacent cuts remain + // unbalanced because the tool call is still open across them. function midStepInjection(): Session { const s = new Session(SessionId('mid-inject')) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -217,7 +219,8 @@ describe('isToolPairingBalanced on an injection turn (no step)', () => { }) describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => { - // The case the log-position scan got wrong. + // A replacement checkpoint has a high log seq but sits at the surface head; + // its cuts are balanced regardless of later raw-log neighbors. function checkpointHeadedSession(): Session { const s = new Session(SessionId('checkpoint')) // A closed turn with a tool step → surface [u1, asst(call), result]. diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 739bfbf8df..0156f13b28 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -150,8 +150,8 @@ export interface Config { persona?: string /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. - * Shape errors fail at load and unknown names fail at assembly. Omitted means - * lexicographic order. See the explicit-tool-order RFC for rationale. + * Shape errors fail at load and unknown names fail at assembly; known names + * hidden in one scope may be absent there. Omitted means lexicographic order. */ toolOrder?: string[] } @@ -159,7 +159,8 @@ export interface Config { /** * Interpolate strict `{{variable}}` references, drop empty sections, and join * the rest with blank lines. Malformed, unknown, or undefined references throw; - * substituted values are not scanned again. + * a lone `{{` without any later `}}` is literal prose, and substituted values + * are not scanned again. * @param assembly - the assembly whose sections and variables to render. * @returns the rendered prompt, or `''` when all sections are empty. */ @@ -243,7 +244,8 @@ export class SystemPrompt extends Service { /** * Register an ordered prompt section in the calling context's scope. A scoped * section shadows a global section with the same name; duplicates within one - * layer and non-finite orders throw. + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. * @param section - the section to register. * @returns the exact Cordis effect disposer. */ @@ -282,8 +284,10 @@ export class SystemPrompt extends Service { } /** - * Register a tool-schema provider in the calling context's scope. - * @param provider - evaluated for each assembly. + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. * @returns the exact Cordis effect disposer. */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void { @@ -314,7 +318,8 @@ export class SystemPrompt extends Service { /** * Register a prompt variable in the calling context's scope. Scoped values - * shadow globals; invalid or duplicate names throw. + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. * @param name - the `[a-z][a-z0-9_]*` reference name. * @param provider - evaluated for each assembly. * @returns the exact Cordis effect disposer. @@ -352,8 +357,9 @@ export class SystemPrompt extends Service { } /** - * Assemble global and scoped providers, apply canonical ordering, then run - * the assembly waterfall. Scoped sections and variables shadow globals. + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. * @param context - the optional scope and plugin-defined assembly fields. * @returns the authoritative post-waterfall assembly. */ diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 89b53cee7c..ac967265c9 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -11,16 +11,16 @@ tools: mode: native # native (default) | code | both ``` -`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and incompatible tool-order configuration rejects prompt assembly. +`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol. ### Public API - `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber. -- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools; multiple masks intersect and scope-local tools merge afterwards. Unknown, local, or reserved names and empty filters reject. This is visibility composition, not an authority boundary; see the [scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). +- `ctx.tools.restrict(filter)` applies an agent-scoped allow/deny mask to global tools and throws from a plain context. The filter is snapshotted at registration; multiple masks intersect and scope-local tools merge afterwards. Deny masks admit later unnamed globals, while allow masks exclude later names. Unknown, local, or reserved names and empty filters reject. This is live visibility composition, not an authority boundary; see the [scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals). - `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed. - `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)). - `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber. -- `ctx.tools.execute(exec)` snapshots arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, and snapshots the authoritative outcome before final observation. +- `ctx.tools.execute(exec)` losslessly snapshots and freezes arguments, assigns an opaque token, runs the complete policy/dispatch/result pipeline, then independently snapshots the authoritative outcome before final observation. Invalid arguments use the same result path without reaching policy or the body; around wrappers may replace only `signal`. ### Injected services @@ -80,7 +80,7 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` definition validates model arguments before execution and turns violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed and defaults are not applied. Raw-registered tools own their validation. +A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. @@ -88,15 +88,20 @@ Optional `timeoutMs` must be positive and finite; it is policy metadata, not mod ### Structured-output schema subset -`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It supports scalar types, objects, arrays, scalar `enum`/`const`, and annotations. Unsupported or inconsistent keywords fail through `OutputSchemaError`; `validateStructuredValue()` returns path-qualified violations. +`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing. ### Tool-owned UI presentation -Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names. The `card` discriminator is `generic`, `terminal`, or `diff`; returning `undefined` selects generic fallback. Result-time presentation may read JSON-serializable `result.meta`, which is persisted for replay. The [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the shapes and rationale. +Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: + +- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. + +Returning `undefined` selects generic fallback. Presenters depend only on their arguments because UIs call them during live streaming and log replay. Result presentation may read JSON-serializable `result.meta`, which persists with the result; `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [render-intent RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns the rationale. ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope. Each program binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only program output re-enters model context. Each JSON-normalized binding re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials reject that binding, ordinary side effects are not rolled back, and mid-run `additionalContext` is omitted to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; failures surface as `CodeRunFailedError`. See the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md) and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. ### What is NOT here (TODO) diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 8fce79f13f..8156e21bb5 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -1,5 +1,7 @@ /** - * Code Mode: the `run_code` tool and its dispatch bridge. + * Code Mode `run_code` transport. Programs call the registry's agent-visible + * tools through nested, sequential executions; each sub-dispatch is logged for + * reconstruction, while only the outer curated result enters model history. * @module @deepseek-ai/dsh-tools/src/code-mode */ @@ -119,7 +121,7 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined { /** * Build the `run_code` {@link ToolDefinition}: one required `code` parameter, - * executed through the dispatch bridge described in the module doc. The + * executed through the dispatch bridge described above. The * registry reserves it as presentation infrastructure under non-native modes, * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 1f3501e3c3..988c6a0268 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -117,7 +117,7 @@ declare module 'cordis' { } } -// TODO(review): revisit these shapes when concurrency metadata becomes useful +// TODO(concurrency): revisit these shapes when concurrency metadata becomes useful // (for example, a read-only hint that would permit safe parallel execution). /** Tool output, optionally with lossless-JSON presentation metadata persisted for replay. */ @@ -251,13 +251,21 @@ export interface ToolExecutionResult { meta?: unknown } -/** Pre-dispatch decision. Input rewriting is excluded because arguments are already logged and presented. */ +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; + * `ask` runs only after an approval service returns `allowed-once` and otherwise + * denies. Input rewriting is excluded because arguments are already logged and + * presented. + */ export type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string } -/** Post-dispatch decision: accept or replace content, attach context, or block with corrective feedback. */ +/** + * Post-dispatch decision: accept or replace content, attach context for the next + * request, or block by turning corrective feedback into an error result. + */ export type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext } | { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext } @@ -298,7 +306,12 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { - /** Model presentation: native schemas, `run_code` plus SDK, or both. Code modes require a TypeScript runtime. */ + /** + * Model presentation. `native` (default) sends every visible schema; `code` + * sends only `run_code` plus a generated SDK prompt; `both` sends both forms. + * Code modes require a TypeScript runtime and fail prompt assembly when it is + * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. + */ mode?: ToolPresentationMode } @@ -393,7 +406,10 @@ export class ToolRegistry extends Service { } } - /** Build one scope's wire schemas and pre-restriction names for prompt-order validation. */ + /** + * Build one scope's wire schemas and names for prompt-order validation. + * Restrictions do not make known tools invalid, but a mode collapse does. + */ private wireSchemas(scope?: ScopeKey): ToolProviderResult { const view = this.view(scope) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) @@ -655,7 +671,8 @@ export class ToolRegistry extends Service { /** * Execute through pre-policy, guards, around-dispatch, post-policy, and final * notification. Tool and listener failures resolve as materialized error - * results; an invisible tool reports `UNKNOWN_TOOL`. + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. * @param exec - the typed same-process call input. The registry assigns its * correlation token before policy begins. * @returns the materialized final result. diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 3a8284fa59..e1a0dc43a6 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,7 +1,9 @@ /** - * Structured-output JSON Schema subset: the vocabulary a caller uses to demand a - * machine-readable result from a subagent (`SubagentStartRequest.outputSchema`) or a workflow - * `agent()` call. + * Structured-output JSON Schema subset for subagents and workflows. It supports + * one scalar `type`; object `properties`/`required`/boolean + * `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued + * annotations. Unsupported or misplaced keywords reject rather than being + * accepted without enforcement, and structured-output roots must be objects. * @module dsh-tools/json-schema */ diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 92769e630b..e2fd2cf9ec 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -167,8 +167,10 @@ export interface TerminalResultView { } /** - * A completed file mutation rendered as an inline diff card, the *result-time* analogue of - * {@link DiffCallView}. + * A completed file mutation rendered as an inline diff card, the result-time + * analogue of {@link DiffCallView}. Because a completed UI update replaces the + * pending card content, mutation tools return this even when it repeats the + * call-time diff; otherwise raw result text would replace the diff. */ export interface DiffResultView { card: 'diff' diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index bb3a18c191..9e671f6c73 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -310,7 +310,8 @@ export interface DefineToolOptions { /** * Define a first-party tool whose execution and presentation arguments are - * inferred from its per-property schema. + * inferred from its per-property schema. Raw JSON-Schema definitions remain + * valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar. * @param options - the tool's name, description, typed parameter schema, * execute body, and optional presenters. * @returns a registry-ready definition with strict execution validation and diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index f93d952aad..bd8c08ed62 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -24,8 +24,8 @@ function pad(indent: number): string { /** A one-line JSDoc block for a schema `description`, or no lines when there is none. */ function docLines(description: unknown, indent: number): string[] { if (typeof description !== 'string' || description.length === 0) return [] - // Keep the doc a single-line comment per property: descriptions are prose (possibly with - // newlines); collapse whitespace so the rendered SDK stays stable and compact. + // Collapse prose to stable one-line docs and escape comment closers so a + // schema description cannot terminate generated JSDoc. const collapsed = description.replace(/\s+/g, ' ').trim() return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`] } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 21ba6f5e4a..fe5ba5402a 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -350,8 +350,8 @@ describe('the run_code dispatch bridge', () => { return { logs: [], value: 'done' } } - // Model a timeout-style outer wrapper: it temporarily installs a signal, delegates, then - // restores the exact prior shape. + // Freeze the nested observer's parent correlation. If that were the live + // outer execution object, the timeout-style wrapper could not restore it. ctx.on('tools/execute', async (exec, next) => { if (exec.name !== RUN_CODE_NAME) return next() const previous = exec.signal diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 8d50109491..b171817e35 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -703,7 +703,9 @@ describe('ToolRegistry', () => { }) it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => { - // The async probe distinguishes nested LIFO teardown from a sibling effect. + // Registry methods return the exact Cordis effect disposer so a composite yield places + // unregistration at its LIFO position. A wrapper would create a concurrent sibling; this async + // probe yields during earlier teardown and would then observe the tool already removed. const ctx = await setup() const order: string[] = [] const fiber = await ctx.plugin(Object.assign((inner: Context) => { @@ -990,20 +992,18 @@ describe('schema DSL edge cases', () => { port: { type: 'number' }, }, }) - // no 'required' key in the nested object because nothing is required const config = jsonSchema.properties['config'] as Record expect('required' in config).toBe(false) }) }) -describe('schema DSL regressions (Codex review round 2)', () => { +describe('schema DSL optional and nested contracts', () => { it('InferArgs makes non-required keys genuinely optional (omittable)', () => { type Args = InferArgs<{ path: { type: 'string'; required: true } limit: { type: 'number' } }> expectTypeOf().toEqualTypeOf<{ path: string; limit?: number }>() - // omitting the optional key is assignable — the actual regression const omitted: Args = { path: '/tmp' } expect(omitted.limit).toBeUndefined() }) diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 26524fad24..c531096206 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) -// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the +// ctx.fs uses the local backend; load @deepseek-ai/dsh-fs-policy for the // freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit. ``` diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 2af10d5101..fba2a240eb 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -1,7 +1,7 @@ /** - * Cordis-free local-filesystem I/O for `@deepseek-ai/dsh-fs-local`. Kept separate from the - * service class (mirroring `dsh-bash-local`'s `run.ts`) so the raw stat/read/write/edit - * mechanics can be unit-tested without a Context. + * Cordis-free local filesystem mechanics. This provider layer returns validated UTF-8 text, + * streams large files, and rejects binary data; line windows belong to `dsh-tool-fs`. Writes + * stage an exclusive owner-only file in a private sibling directory and atomically rename it. * @module @deepseek-ai/dsh-fs-local/fsio */ @@ -108,8 +108,9 @@ export interface LocalDirEntry { } /** - * Resolve a path to its absolute display path and realpath identity. - * + * Resolve a path to its absolute display path and realpath identity. For a missing target, + * realpath the nearest existing ancestor and append the missing suffix, preserving identity + * across symlinked ancestors before and after creation. * @param cwd - base directory a relative `path` resolves against. * @param path - absolute or relative path; empty/whitespace-only throws `FS_NOT_FOUND`. * @returns the absolute display path plus the realpath-derived stable target key. @@ -469,9 +470,8 @@ export async function readForEdit( } /** - * Best-effort read of a file's current text for a before/after diff basis, used by an - * overwrite. - * + * Best-effort overwrite diff basis. Binary or invalid UTF-8 returns `null` so the write still + * succeeds and presentation falls back to a whole-file diff. * @param absolutePath - the file to read (typically a target key); it must exist. * @param signal - aborts the read (`FS_ABORTED`). * @returns the LF-normalized text, or null for a binary or non-UTF-8 file. @@ -489,8 +489,8 @@ export async function readTextForDiff(absolutePath: string, signal?: AbortSignal } /** - * Apply a literal replacement to LF-normalized content. - * + * Apply a literal replacement to LF-normalized content. Empty or missing search text throws + * `FS_EDIT_NOT_FOUND`; multiple matches throw `FS_AMBIGUOUS_EDIT` unless `replaceAll` is true. * @param content - the current file content, already LF-normalized. * @param oldString - literal text to find; CRLF inside it is normalized to LF before * matching. diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 80a86252f6..d3e049199c 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -1,7 +1,6 @@ /** - * Local-filesystem implementation of the `ctx.fs` provider seam. {@link LocalFileSystem} - * subclasses {@link FileSystem} and backs the seven text-storage primitives with the host - * filesystem via {@link module:@deepseek-ai/dsh-fs-local/fsio}. + * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases + * share stale guards, and writes through a symlink update its target without replacing the link. * @module @deepseek-ai/dsh-fs-local */ @@ -177,6 +176,7 @@ export class LocalFileSystem extends FileSystem { const existing = await probe(target.targetKey) // Stale guard before literal matching: an edit based on an old read reports // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content. + // Missing targets use the same stale code on guarded and unconditional edit paths. if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION') if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') // expected === undefined: unconditional edit of the current content — no diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index e63e256c7e..0cccb1aa5b 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -37,7 +37,7 @@ Three `fs/*` events (declared by `@deepseek-ai/dsh-fs`, dispatched by `@deepseek ## Observed state is the prior-observation record; freshness is provider CAS -Observed state is a weak owner-to-target version map updated after every successful read or mutation. The plugin performs no filesystem I/O: it checks whether a version was observed and supplies that version to the provider's atomic mutation guard. State is discarded on plugin disposal and is not persisted across sessions. +Observed state is a weak owner-to-target version map updated after every successful read or mutation; presence alone is the prior-observation record. The plugin performs no filesystem I/O: it supplies the observed version to the provider's atomic mutation guard. A windowed read observes the whole file version, so a later targeted edit is allowed only while that file remains unchanged. State is discarded on plugin disposal and is not persisted across sessions. ## Single-slot, first-wins diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index ed8083891f..c38e7494e0 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -1,7 +1,8 @@ /** - * The fs-policy plugin: observed-state, read-before-edit, and "write/edit must be based on the - * version you read" — added on top of the `ctx.fs` provider seam through the `fs/*` event - * gate, not through a method service. + * Event-only filesystem observation policy; it registers no service. A weak owner/target map + * records every successful read or mutation, single-slot intent listeners supply that version, + * and the provider performs the atomic freshness check. Without this plugin, tools retain the + * bare provider's unconditional mutation behavior. See the package README for composition rules. * @module @deepseek-ai/dsh-fs-policy */ @@ -110,7 +111,8 @@ export function apply(ctx: Context): void { // fs/edit-intent: occupy the single decision slot — do not call next(). ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor))) - // fs/observed: synchronous, side-effect-only WeakMap write. + // fs/observed must remain synchronous and non-throwing: the mutation already succeeded, and + // emit does not await promises. WeakMap.set satisfies that contract. ctx.on('fs/observed', (target, version, actor) => { gate.observe(target, version, actor) }) diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index cfe22e02d7..85c6ae0b52 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,6 +1,4 @@ -/** - * Tests for the fs-policy plugin: it registers no service, only the three `fs/*` listeners. - */ +/** Event-level policy tests; no filesystem provider is needed because the plugin performs no I/O. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index c062118531..1c70226aff 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -1,8 +1,8 @@ /** - * The filesystem provider seam (`ctx.fs`): an abstract service defining the text-storage - * primitives a backend provides — resolve a path into a stable target, stat its metadata, - * read/stream its text, write it atomically with an explicit intent, and apply a guarded - * literal edit — without saying how. + * Filesystem text-storage provider seam. Backends own stable target identity, + * text decoding, binary rejection, and atomic mutations. Read windows and + * observed-state policy stay in consumer and policy plugins; `editText` remains + * here so version check, literal match, and rewrite share one critical section. * @module @deepseek-ai/dsh-fs */ @@ -41,26 +41,25 @@ declare module 'cordis' { interface Events { /** - * Single-slot decision: produce the write intent for the next {@link - * FileSystem.writeText}. - * + * Single-slot decision for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. * @param target - the resolved target about to be written. * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise /** - * Single-slot decision: produce the optional version guard for the next {@link - * FileSystem.editText}. - * + * Single-slot decision for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. * @param target - the resolved target about to be edited. * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> /** - * Record that an actor observed a target at a version, after a successful read/write/edit. - * + * Record a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. * @param target - the target that was read/written/edited. * @param version - the version the actor now holds as its observation. * @param actor - the observing tool-execution context; undefined records nothing useful. @@ -71,9 +70,10 @@ declare module 'cordis' { } /** - * Abstract filesystem provider service. Subclass, implement the seven storage primitives, and - * load the subclass as a plugin — it registers as `ctx.fs` (one implementation per context; - * loading a second throws, cordis' standard duplicate-service behavior). + * Abstract filesystem provider. Targets must preserve identity across aliases; + * reads expose regular UTF-8 text or typed errors, listings are stable and + * content-free, and mutations are atomic. Optional guards add stale protection + * without changing the unguarded provider contract. */ export abstract class FileSystem extends Service { constructor(ctx: Context) { @@ -139,8 +139,9 @@ export abstract class FileSystem extends Service { abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise /** - * Apply a literal edit to an existing UTF-8 text file. - * + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. * @param target - the resolved target to edit. * @param edit - the literal search/replace request. * @param expected - the version guard; omit for an unconditional edit. diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index 1f86f03865..73c8ff4837 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -90,11 +90,10 @@ export interface FsDirEntry { } /** - * The explicit intent of a guarded {@link FileSystem.writeText} call. `createIfAbsent` creates - * a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy - * plugin uses when the owner has no prior read). `replaceIfVersion` replaces only when the - * target exists at the observed version; a missing target or a version mismatch throws - * `FS_STALE_VERSION`. + * Guarded write intent. `createIfAbsent` rejects an existing target with + * `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with + * `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional + * create-or-overwrite, not a third union arm. */ export type FsWriteIntent = | { kind: 'createIfAbsent' } diff --git a/packages/fs/tool-fs/src/diff.ts b/packages/fs/tool-fs/src/diff.ts index d3e7e7c1c1..cf5b808662 100644 --- a/packages/fs/tool-fs/src/diff.ts +++ b/packages/fs/tool-fs/src/diff.ts @@ -1,5 +1,6 @@ /** - * Result-time contextual-diff computation for the `write`/`edit` tools. + * Result-time contextual diff presentation for write and edit. Storage returns before/after + * text; this model-facing layer derives one three-line-context card per applied hunk. * @module @deepseek-ai/dsh-tool-fs/src/diff */ @@ -21,7 +22,8 @@ export type FsDiffMeta = { diffs: FileDiff[] } /** * Compute one {@link FileDiff} per hunk between `before` and `after`, each carrying the - * applied change plus {@link DIFF_CONTEXT} context lines. + * applied change plus {@link DIFF_CONTEXT} context lines. Pure insertions use `oldText: null`, + * patch-only no-newline markers are omitted, and scattered replacements remain separate hunks. * * @param path - the path stamped on every produced diff (the model-facing `file_path`; the * bridge relativizes it). @@ -65,7 +67,8 @@ function isFileDiff(value: unknown): value is FileDiff { } /** - * Narrow opaque live or replayed result metadata to non-empty file diffs. + * Narrow opaque live or replayed result metadata to non-empty file diffs. Malformed metadata + * returns `undefined` so presentation can fall back instead of throwing during replay. * @param meta - result metadata. * @returns validated hunks, or `undefined` for absent or malformed data. */ diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index e9a7c60b66..c347d7d958 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -1,6 +1,7 @@ /** - * The model-facing `edit` tool: update an existing UTF-8 text file by replacing literal text, - * requiring a unique match by default. + * Model-facing literal edit, unique-match by default. It obtains an optional guard from the + * single intent slot, calls `ctx.fs.editText` without a separate stat, then records the observed + * version; no policy means an unconditional atomic edit. * @module @deepseek-ai/dsh-tool-fs/src/edit */ @@ -88,7 +89,7 @@ export function applyEditTool(ctx: Context): void { ) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // The result-time applied-hunk diff (before→after with context lines). + // An edit necessarily changes content, so result metadata carries at least one applied hunk. const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], @@ -106,7 +107,8 @@ export function applyEditTool(ctx: Context): void { locations: [{ path: args.file_path }], } }, - // Result-time display: the applied contextual-diff hunks carried on `meta`. + // Applied metadata replaces the call-time snippet; errors or malformed replay metadata use + // the generic result rendering. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 263a53b108..16d352f1b7 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,6 +1,7 @@ /** - * The model-facing filesystem tool suite (`read`, `write`, `edit`) over the `ctx.fs` provider - * seam. This single plugin registers all three tools. + * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, + * read windows, formatting, and observation events, never a concrete provider. An optional + * event policy supplies mutation guards; without one the tools use unconditional provider calls. * @module @deepseek-ai/dsh-tool-fs */ diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index f23c37be9c..943ff98f61 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -1,7 +1,7 @@ /** - * Cordis-free read rendering for `@deepseek-ai/dsh-tool-fs`: turn a file's decoded text into a - * bounded, line-numbered window (offset/limit, byte cap, per-line truncation) and format it as - * the model-facing text block. + * Pure read presentation: turn provider-decoded text into a bounded, line-numbered window and + * model-facing envelope. Chunk scanning caps the current line, so even one newline-free giant + * line cannot grow memory without bound. * @module @deepseek-ai/dsh-tool-fs/read-render */ @@ -102,8 +102,8 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string } /** - * Build a bounded, line-numbered window from a file's decoded text chunks. - * + * Build one window from streamed or whole-file chunks, enforcing line and byte caps and throwing + * `FS_NOT_FOUND` when the requested offset is past EOF. * @param chunks - decoded text chunks in file order; chunk boundaries carry no meaning. * @param request - the resolved window; the caller has already applied its defaults and caps. * @param displayPath - the caller-facing path used in the offset-out-of-range error. diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index e7073d1356..77bb76eb20 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -1,6 +1,6 @@ /** - * The model-facing `read` tool: inspect a UTF-8 text file and return line-numbered content - * with pagination guidance. + * Model-facing UTF-8 read. It performs one provider stat for type, routing, and observed version, + * streams large or size-unknown files, renders a bounded window, then emits the observation. * @module @deepseek-ai/dsh-tool-fs/src/read */ @@ -90,6 +90,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) // One stat: type check + size routing + the version recorded as observed. + // A concurrent write can only make a later guarded mutation fail stale and require reread. const info = await ctx.fs.stat(target, exec.signal) if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') @@ -119,7 +120,8 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the - // read's offset (defaulting to 1). + // read's offset (defaulting to 1). The window reflects raw args, so an omitted limit keeps + // the title bare instead of smuggling config into this pure presenter. presentCall(args): GenericCallView { const { offset, limit } = args const window = limit !== undefined && limit > 0 diff --git a/packages/fs/tool-fs/src/session-cwd.ts b/packages/fs/tool-fs/src/session-cwd.ts index 11b216acf4..2f53d630ce 100644 --- a/packages/fs/tool-fs/src/session-cwd.ts +++ b/packages/fs/tool-fs/src/session-cwd.ts @@ -3,6 +3,8 @@ * agent's per-session workspace (`exec.agent.session.header.cwd`), so each ACP session's * `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how * `dsh-tool-bash` defaults a bash `workdir` to the session cwd. + * Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading + * `process.cwd()` at the tool seam. * @module @deepseek-ai/dsh-tool-fs/session-cwd */ diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 10d52427bd..86fe186e3b 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -1,5 +1,7 @@ /** - * The model-facing `write` tool: create or fully replace a UTF-8 text file. + * Model-facing full-file write. It obtains an optional intent from the single policy slot, calls + * `ctx.fs.writeText` without a stat, then records the resulting version; no policy means an + * unconditional atomic create-or-overwrite. * @module @deepseek-ai/dsh-tool-fs/src/write */ @@ -67,7 +69,8 @@ export function applyWriteTool(ctx: Context): void { const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal) // Record the observed version (a no-op when no policy plugin listens). ctx.emit('fs/observed', target, outcome.version, exec) - // Attach a contextual hunk as `meta` only for an overwrite (a before-version exists). + // Overwrites carry applied hunks. Creates have no prior text, so result presentation uses + // the args-derived whole-file diff instead. const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : [] return { content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }], @@ -87,7 +90,8 @@ export function applyWriteTool(ctx: Context): void { }, // Result-time display: a `diff` card so the completed `tool_call_update` re-installs the // diff rather than the model-facing result text (an ACP `tool_call_update.content` REPLACES - // the call's content, so a text result would clobber the pending diff card). + // the call's content, so a text result would clobber the pending diff card). Overwrites use + // applied metadata; creates and identical overwrites use the replay-safe args fallback. presentResult(args, result: ToolResult): DiffResultView | undefined { if (result.isError) return undefined const diffs = diffsFromMeta(result.meta) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index b90b50046e..26a81343ff 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,7 +1,8 @@ /** - * Integration tests: the real local backend (`dsh-fs-local`) plus the model tools - * (`dsh-tool-fs`) as the executor, exercised through `ctx.tools.execute()` so nothing bypasses - * the tool registry. Two deployments. + * End-to-end tool-registry tests against the real local backend. The policy deployment verifies + * observed-state and guarded mutation; the bare deployment proves unconditional tools have no + * policy-service dependency. Assertions read files back byte-for-byte rather than trusting tool + * messages. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,7 +20,7 @@ import * as ToolFs from '@deepseek-ai/dsh-tool-fs' let dir: string let ctx: Context let fiber: Awaited> -// A stable session object stands in for an agent session (the file-state owner). +// No header cwd: sessionCwd returns undefined and the provider's configured test dir applies. const session = { header: {} } let callCounter = 0 diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 6dcb0e84f9..1fdb75637a 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,5 +1,6 @@ /** - * Consumer-surface tests for the filesystem tools as the EXECUTOR. + * Consumer-surface tests over a fake provider and the real policy collaborator: schemas, + * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. */ import { describe, expect, it, vi } from 'vitest' diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index bb2926f4eb..1e205f7387 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,6 +1,9 @@ /** - * Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing the same tool call - * with identical arguments. + * Advisory repeat-call loop breaker. It never registers, blocks, or rewrites a tool; configured + * consecutive canonical calls add source-attributed context after downstream post-policy. The + * loop logs that model-visible reminder as reconstructable context. Counters are per agent and + * in-memory, so one agent cannot trip another and resumed sessions start fresh. Named exports + * preserve loader metadata. See the package README for chain semantics and thresholds. * @module @deepseek-ai/dsh-repeat-tool-guard */ diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 1edea0ffa6..c9449bd67a 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). - **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. -- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. Event-specific output applies only when its discriminator matches the firing event, while top-level fields remain event-agnostic. The parser is total and leaves successful non-JSON output to the bridge. +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. - **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no seam awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence). diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts index a7bf4457f3..273e86f312 100644 --- a/packages/hooks/hook-protocol/src/codec.ts +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -1,6 +1,7 @@ /** - * Parse a finished hook command's process outcome (exit code + stdout + stderr) into the - * dialect-neutral {@link HookOutput} both bridges map from. + * Decode hook process outcomes for both dialects. Exit 0 may carry structured + * JSON or plain stdout; exit 2 blocks with stderr as the reason; every other + * exit is a non-blocking error. Bridges decide which recognized fields apply. * @module @deepseek-ai/dsh-hook-protocol/codec */ @@ -44,11 +45,15 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision'] } /** - * Decode process output into the dialect-neutral hook outcome. + * Decode process output into a dialect-neutral hook outcome. This function is + * total: malformed JSON remains plain stdout. When `expectedEventName` is set, + * a missing or different `hookSpecificOutput.hookEventName` discards only its + * event-scoped fields; top-level fields and the claimed discriminator remain. + * Omitting the guard applies the block as-is. * @param exitCode - process exit, or `undefined` when spawn failed. * @param stdout - output parsed as structured JSON only on exit 0. * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2. - * @param expectedEventName - optional event guard for hook-specific output. + * @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard. * @returns the dialect-neutral decoded outcome. */ export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput { @@ -113,8 +118,7 @@ function applyStructured(output: HookOutput, parsed: Record, ex // Always surface the discriminator (for the log/diagnostics), even on a // mismatch — the record should show what the malformed block claimed. if (eventName !== undefined) output.hookEventName = eventName - // The schemas key this block by event: when a caller passes the firing event - // (`expectedEventName`), the block's `hookEventName` must name it. + // A missing or mismatched discriminator cannot affect the firing event. if (expectedEventName !== undefined && eventName !== expectedEventName) { return } diff --git a/packages/hooks/hook-protocol/src/detached.ts b/packages/hooks/hook-protocol/src/detached.ts index 4bd31dcc74..8fa21cadf2 100644 --- a/packages/hooks/hook-protocol/src/detached.ts +++ b/packages/hooks/hook-protocol/src/detached.ts @@ -1,5 +1,7 @@ /** - * Quiescence tracking for a bridge's DETACHED hook runs. + * Quiescence tracking for emit-shaped hook runs that no seam awaits. Bridges + * track the run plus its continuation, pass the tracker signal into execution, + * and drain on disposal so no process or late callback outlives the fiber. * @module @deepseek-ai/dsh-hook-protocol/detached */ diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index 518ca51c1f..025250cdac 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -1,8 +1,8 @@ /** - * Append helpers for the log-only `hook/*` session events — the durable record that a hook ran - * and what it decided. Thin wrappers over `session.append` so a bridge does not hand-build the - * payloads (and so the `turn`-enclosure + invoked/result pairing stay consistent across both - * bridges). + * Append helpers for durable, log-only hook events. They carry no surface + * intent and must remain turn-enclosed and invoked/result paired. Mid-turn hook + * points satisfy that boundary; SessionStart records injected context instead + * and does not append `hook/*` outside a turn. * @module @deepseek-ai/dsh-hook-protocol/events */ @@ -83,8 +83,9 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): } /** - * Append the durable result paired with `hook/invoked`, normalizing its decision, - * bounded stderr summary, and optional exit code. + * Append the durable result paired with `hook/invoked`. The recorded decision + * is the parsed decision, then `stop` for `continue:false`, else `pass`; stderr + * is trimmed and capped, and an absent process exit stays omitted. * @param session - the session whose open turn records the event. * @param record - the outcome to record: the decoded output plus the summary cap and duration. */ diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index 835a877209..e342665057 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -1,8 +1,8 @@ /** - * `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex hook wire - * protocol. not a cordis plugin: it registers nothing and injects nothing. It is a LIBRARY of - * dialect-neutral primitives the two bridge plugins (`dsh-hooks-claude`, `dsh-hooks-codex`) - * import to avoid re-implementing the identical halves of the protocol. + * Shared, non-plugin hook protocol library: matching, command execution and + * decoding, restrictive outcome merging, durable event helpers, and detached + * run quiescence. Claude Code and Codex bridges own their distinct payloads, + * environment rules, matcher mode, and typed seam mappings. * @module @deepseek-ai/dsh-hook-protocol */ diff --git a/packages/hooks/hook-protocol/src/matcher.ts b/packages/hooks/hook-protocol/src/matcher.ts index 3c68891002..036954a59c 100644 --- a/packages/hooks/hook-protocol/src/matcher.ts +++ b/packages/hooks/hook-protocol/src/matcher.ts @@ -1,6 +1,8 @@ /** - * The matcher primitive shared by both hook dialects: decide whether a matcher pattern selects - * a given query (a tool name, a session source, …). + * Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/ + * pipe patterns as literal alternatives and other patterns as regex; Codex + * treats every non-empty pattern as an unanchored regex. Missing, empty, and + * `*` match all; invalid regexes silently match nothing. * @module @deepseek-ai/dsh-hook-protocol/matcher */ @@ -15,8 +17,9 @@ function isMatchAll(matcher: string | undefined): boolean { const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/ /** - * Whether `matcher` selects `query` under the given dialect {@link MatcherMode}. - * + * Whether `matcher` selects `query` under the given dialect. Claude literal + * patterns exact-match pipe-separated alternatives; all other patterns are + * unanchored regexes. Invalid regexes return `false` rather than throwing. * @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels. * @param query - the candidate value (a tool name, a session source, …). * @param mode - the dialect deciding literal-vs-regex interpretation of the pattern. diff --git a/packages/hooks/hook-protocol/src/merge.ts b/packages/hooks/hook-protocol/src/merge.ts index 8a00c5ae78..c121135501 100644 --- a/packages/hooks/hook-protocol/src/merge.ts +++ b/packages/hooks/hook-protocol/src/merge.ts @@ -1,6 +1,8 @@ /** - * Merge the outcomes of MULTIPLE hooks that matched one hook point into a single - * most-restrictive {@link MergedHookOutcome}. + * Merge matched hooks into one most-restrictive outcome. Permission precedence + * is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons + * for the winning rank are joined; and context and system messages accumulate + * in hook order. * @module @deepseek-ai/dsh-hook-protocol/merge */ @@ -59,9 +61,7 @@ function decisionForRank(maxRank: number): MergedDecision { */ export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome { let maxRank = 0 - // Reasons collected per RANK, so the merged reason can be the one explaining the WINNING - // decision (a deny-winning outcome surfaces deny reasons; an ask-winning outcome surfaces ask - // reasons). + // Keep reasons per rank so only objections explaining the winning decision surface. const reasonsByRank = new Map() let stop = false let stopReason: string | undefined diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index 086a5119e1..fefb6936c9 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -1,8 +1,8 @@ /** - * Run one configured command hook through the `ctx.bash` executor seam and parse its outcome - * into a {@link HookOutput}. This is where the wire protocol's EXECUTION half lives: feed the - * hook its JSON payload on stdin, hand it the dialect's env vars, honor its timeout, capture - * stdout/stderr/exit, and decode. + * Execute command hooks through `ctx.bash`, using its credential scrub, + * process-group cancellation, and timeout machinery. The bridge supplies the + * trusted stdin payload and dialect environment, then this module decodes the + * captured outcome. * @module @deepseek-ai/dsh-hook-protocol/runner */ @@ -54,9 +54,10 @@ export interface RunHookResult { } /** - * Run `hook` via `bash` with `options.payload` serialized to its stdin, then decode the result - * into a {@link HookOutput}. - * + * Run `hook` with serialized stdin and decode its outcome. A hook-specific + * timeout in seconds overrides the default; trusted environment entries merge + * after the executor scrub. Infrastructure rejection becomes an outcome with + * no exit code, so this function never throws or crashes the calling turn. * @param bash - the executor seam the command runs through. * @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout. * @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout. diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index 8d8ee8d442..7458419a01 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -1,7 +1,7 @@ /** - * Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol, plus the log-only - * `hook/*` session events. Types only — runtime helpers live in the sibling modules - * (`matcher`, `codec`, `runner`, `merge`, `events`). + * Dialect-neutral vocabulary and log-only events shared by the Claude Code and + * Codex hook bridges. Payload construction, matching differences, environment, + * and seam-specific decision mapping remain owned by each bridge. * @module @deepseek-ai/dsh-hook-protocol/types */ @@ -23,7 +23,11 @@ declare module '@deepseek-ai/dsh-session' { matcher?: string handlerId: string } - /** Log-only hook outcome paired to `hook/invoked` by `handlerId`. */ + /** + * Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the + * parsed permission result, `stop` for `continue:false`, or `pass`; exit code + * may be absent, stderr is bounded, and duration is wall-clock runtime. + */ 'hook/result': { turn: number point: string diff --git a/packages/hooks/hooks-claude/src/config.ts b/packages/hooks/hooks-claude/src/config.ts index 17a5848b72..9e8718fbb5 100644 --- a/packages/hooks/hooks-claude/src/config.ts +++ b/packages/hooks/hooks-claude/src/config.ts @@ -1,6 +1,7 @@ /** - * Parse a Claude Code hook config file into the shared {@link MatcherGroup} shape, faithfully - * to CC's `hooks.json` / settings `hooks` key format. + * Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s. + * Command hooks run after `${CLAUDE_PLUGIN_ROOT}` substitution. Other supported hook types are + * parsed but skipped with a warning, matching the bridge's faithful-but-degraded policy. * @module @deepseek-ai/dsh-hooks-claude/config */ @@ -50,8 +51,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri } /** - * Parse a raw Claude Code config object (the value under the `hooks` key, or a `hooks.json` - * whose top level IS that map) into runnable {@link MatcherGroup}s. + * Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are + * ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions + * are applied to every surviving command. * * @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare * event map. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 942d408ccb..755edc99a2 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -1,7 +1,9 @@ /** - * `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code hook config - * (`hooks.json` / a settings file's `hooks` key) on the harness's canonical interception - * seams. + * Bridge for unmodified Claude Code command hooks on harness interception + * seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent + * start/stop; owns Claude payloads, environment and plugin-root substitution; + * and logs but does not honor `updatedInput`. Bespoke behavior should use typed + * native plugins on the same seams. * @module @deepseek-ai/dsh-hooks-claude */ @@ -195,7 +197,8 @@ export function apply(ctx: Context, config: Config): void { return { content: [...ours.content, ...theirs.content], source: ours.source } } - // SessionStart injects context when its detached hook resolves. + // SessionStart injects context when its detached hook resolves; a slow hook + // may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal }) @@ -216,7 +219,8 @@ export function apply(ctx: Context, config: Config): void { if (merged.decision === 'deny') { return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } } - // Our hooks did not block. + // Delegate so later listeners may still rewrite or block, then prepend our + // context only to a downstream allow decision. const downstream = await next() const ours = contextFrom(merged) if (!ours || downstream.kind !== 'allow') return downstream @@ -259,7 +263,7 @@ export function apply(ctx: Context, config: Config): void { }) // A blocking Stop hook forces continuation with its reason. - // TODO(stop-loop-guard): cap consecutive forced continuations. + // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn }) if (merged.decision === 'deny') { @@ -270,8 +274,8 @@ export function apply(ctx: Context, config: Config): void { return next() }) - // --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is observe-only - // this cut). + // SubagentStart may inject child context; SubagentStop only observes. Both + // use the live child's workspace and the generic agent-type matcher subject. ctx.on('subagent/start', (info) => { const child = ctx.get('agents')?.get(info.id) detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index c5ac2bf63b..06ec7ad266 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -293,7 +293,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, adapter) // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's - // child lookup yields undefined and it simply runs the hook. + // child lookup yields undefined and it runs the hook. ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) @@ -302,8 +302,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => await waitFor(() => existsSync(startMarker) && existsSync(stopMarker)) expect(existsSync(startMarker)).toBe(true) expect(existsSync(stopMarker)).toBe(true) - // The markers prove the hook PROCESSES ran, not that the detached `.then` continuations did - // (`touch` lands before the process exits). + // A marker proves only that the process ran. Disposal drains its detached continuation so the + // no-context branch completes before the per-file coverage snapshot instead of racing CI. await hooks.dispose() }) @@ -313,8 +313,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const pidFile = join(dir, 'pid') const marker = join(dir, 'started') const slowHook = join(dir, 'slow.sh') - // Record the hook shell's PID and touch the marker FIRST so the test can tell "the hook is - // genuinely mid-run", then sleep far past the suite timeout. + // Record the PID and marker before sleeping past the suite timeout. Disposal must abort and + // kill the process rather than await its exit or the default ten-minute hook timeout. writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) chmodSync(slowHook, 0o755) writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { @@ -328,9 +328,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() - // Quiescence, not just promptness: the drain resolves only after the run settled, and the - // run settles only after the killed process was reaped — so by the time dispose returns, - // the PID must be GONE (kill(pid, 0) throws ESRCH). + // Disposal reaches quiescence: it returns only after the aborted run settles and the process + // is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. expect(() => process.kill(pid, 0)).toThrow() // The aborted run resolves as a non-blocking error (runHook never rejects), // so the drained continuation must NOT have logged a failure. @@ -359,8 +358,8 @@ describe('hooks-claude bridge — load resilience', () => { }) it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { - // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it would veto the - // prompt (0 model requests) and log a hook/invoked. + // This is the only bridge mount, and its blocking hook would veto the prompt and log an event + // if its listener leaked after disposal. A no-op hook would not expose that leak. const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() @@ -382,7 +381,8 @@ describe('hooks-claude bridge — load resilience', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // Loader must retain this namespace's injection metadata. + // A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing + // load to fail. Guard the shape from postmortem 0001 directly. expect('default' in HooksClaude).toBe(false) expect(HooksClaude.name).toBe('hooks-claude') expect(HooksClaude.inject).toEqual(['bash']) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 79a0af4708..f376708688 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -183,9 +183,8 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', }) it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => { - // Regression: a blocking Stop hook (exit 2) with no stderr yields decision 'deny' + reason - // undefined; the turn must STILL force-continue (the block is what matters), not silently - // stop. + // A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces + // continuation; the script self-limits to one block to avoid a loop. const d = dir() const marker = join(d, 'fired') const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) @@ -382,8 +381,8 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => { it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => { - // Honoring `continue:false` (hard-halt the whole run) is deferred — there is no such - // primitive on the interception seams yet. + // The seams cannot yet honor `continue:false` as a hard halt. The log must still record the + // stop decision while execution and the turn continue normally. const d = dir() const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n') const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) @@ -455,8 +454,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => }) it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // A hook that only adds context must not short-circuit the waterfall: a downstream - // agent/prompt-submit listener (a policy plugin) must still get to block the prompt. + // A context-only hook delegates with `next()` and folds its context, so a downstream policy + // listener can still veto the prompt. const d = dir() const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) @@ -586,8 +585,8 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => { describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => { it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => { - // The bug: the bridge passed no workdir, so hooks ran in the executor default (the server - // launch dir), not session/new.cwd. + // The server launch directory and session cwd deliberately differ. The marker proves the + // bridge passes `session/new.cwd` instead of falling back to the executor default. const serverDir = dir() const sessionDir = dir() const marker = join(sessionDir, 'where') @@ -621,8 +620,8 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server }) it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => { - // SubagentStop looks the child up (recoverable at subagent/end) and runs the hook in the - // CHILD's session cwd, not the executor default. + // `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint` + // receives that agent and runs in the child's cwd rather than the executor default. const serverDir = dir() const childDir = dir() const marker = join(childDir, 'stopwhere') @@ -673,8 +672,8 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () = describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => { it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => { - // Regression for the documented downgrade: session-start injection is detached, so a prompt - // sent immediately need not observe it. + // Session-start injection is detached, so an immediate prompt need not observe it. Assert only + // the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race. const d = dir() const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n') const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 3a5d50b9db..6e554e76bf 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -1,5 +1,7 @@ /** - * Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. + * Parse Codex's five-event hook subset into shared {@link MatcherGroup}s. Only synchronous command + * hooks run; other types and `async: true` commands are recorded as skipped. Codex performs no + * command substitution. * @module @deepseek-ai/dsh-hooks-codex/config */ @@ -30,8 +32,8 @@ function asObject(value: unknown): Record | undefined { } /** - * Parse supported synchronous command hooks, recording skipped entries and - * ignoring malformed configuration rather than failing boot. + * Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather + * than failing boot; unsupported or asynchronous hooks are returned in `skipped`. * @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map. * @returns the runnable per-event groups plus the skipped hooks with their reasons. */ diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 72076d69a9..9bba1b80a3 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -1,6 +1,8 @@ /** - * `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex `hooks.json` on the - * harness's canonical interception seams. The CODEX DIALECT half of the hooks subsystem. + * Bridge for unmodified Codex command hooks on harness interception seams. It + * supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only + * matchers, snake_case payloads without a trailing newline, no hook environment + * or command substitution, and block-only decisions; allow/ask do not grant. * @module @deepseek-ai/dsh-hooks-codex */ @@ -126,8 +128,8 @@ export function apply(ctx: Context, config: Config): void { // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, }, () => performance.now()) - // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN (non-JSON) stdout as - // additionalContext. + // Clean plain stdout becomes context only when no structured context + // exists; nonzero output and raw JSON never leak as prose. if (opts.plainStdoutAsContext === true && output.exitCode === 0 && output.additionalContext === undefined && output.stdout.length > 0 && !output.stdout.startsWith('{')) { @@ -159,7 +161,8 @@ export function apply(ctx: Context, config: Config): void { return { content: [...ours.content, ...theirs.content], source: ours.source } } - // SessionStart injects plain stdout when its detached hook resolves. + // SessionStart injects plain stdout when its detached hook resolves; a slow + // hook may miss the first request. // TODO(session-start-gating): add a startup gate before promising first-turn delivery. ctx.on('agent/session-start', (agent, source) => { detached.track(runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 25b5c2f29c..0deb84f5b3 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -143,8 +143,8 @@ describe('hooks-codex bridge', () => { it('disposing the bridge fiber removes its listeners (HMR safety)', async () => { const dir = configDir() - // A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it would veto the - // prompt (0 model requests) and log a hook/invoked. + // A leaked listener would let this blocking hook veto the prompt and log an invocation; a + // no-op hook would pass even when leaked. const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] }) const adapter = new MockAdapter([textResponse('ok')]) @@ -170,8 +170,8 @@ describe('hooks-codex bridge', () => { const dir = configDir() const pidFile = join(dir, 'pid') const marker = join(dir, 'started') - // Record the hook shell's PID and touch the marker FIRST so the test can tell "the hook is - // genuinely mid-run", then sleep far past the suite timeout. + // Record the PID and marker before sleeping past the suite timeout. Disposal must abort the + // tracked process through `runPoint`, not await its natural exit. const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`) writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] }) const ctx = new Context() @@ -190,9 +190,8 @@ describe('hooks-codex bridge', () => { await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() - // Quiescence, not just promptness: the drain resolves only after the run settled, and the - // run settles only after the killed process was reaped — so by the time dispose returns, - // the PID must be GONE (kill(pid, 0) throws ESRCH). + // Disposal reaches quiescence only after the aborted run settles and the process is reaped, so + // `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain. expect(() => process.kill(pid, 0)).toThrow() // The aborted run resolves as a non-blocking error (runHook never rejects), // so the drained continuation must NOT have logged a failure. diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 9492a94680..c287d86b23 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -70,8 +70,8 @@ describe('hooks-codex coverage — decision mapping paths', () => { }) it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => { - // Context alone is not a veto: a downstream agent/prompt-submit listener (a policy plugin - // registered after the bridge) must still get to block. + // Context alone is not a veto: the bridge delegates with `next()` and folds its context, so a + // downstream policy listener can still block. const d = dir() hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('should not run')]) @@ -437,8 +437,9 @@ describe('hooks-codex coverage — decision mapping paths', () => { }) it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => { - // The plain-stdout→context fold is gated on exitCode === 0, matching the codec's - // structured-stdout rule. + // SessionStart cannot block, but non-clean stdout still must not become context. The marker + // waits for detached completion; `echo stale; exit 2` then proves the exit-code gate matches + // the codec's structured-stdout rule. const d = dir() const marker = join(d, 'ran') hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 1238e6f4c5..30760a8fbc 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -73,9 +73,8 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Paranoid by design: `code` and the HTTP status are ALREADY captured above (and passed - // to LlmError below), so the only thing this `try` can add is a richer - // provider-supplied message. + // Only swallow error-body parsing: status and code are already captured, + // so malformed gateway JSON must not mask the actionable HTTP failure. } throw new LlmError(message, code, response.status) } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index afa1a3699c..b816e2e7cf 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,6 +1,7 @@ /** - * DeepSeek LLM adapter plugin: registers a {@link DeepSeekAdapter} for the configured model - * names on `ctx.llm`. + * Register a {@link DeepSeekAdapter} for configured model names on `ctx.llm`. Configuration uses + * Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`, + * as shown in the package README, rather than reading ad hoc files. * @module @deepseek-ai/dsh-llm-deepseek */ diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 40da29e242..6c43772dbc 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,6 +1,8 @@ /** - * Serialize harness vocabulary (`GenerateOptions`, `Message[]`) into the DeepSeek - * chat-completions request body. + * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text + * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. + * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by + * thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected. * @module dsh-llm-deepseek/serialize */ diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index 0bc56e61bb..17080b10b3 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,4 +1,8 @@ /** + * Decode an SSE byte stream into event `data` payloads. Network reads may split UTF-8 or lines; + * CRLF, comments, non-data fields, and multi-data events are handled per SSE rules. The literal + * `[DONE]` is yielded so the caller owns final flushing, and EOF before it raises {@link LlmError}. + * * Minimal SSE (text/event-stream) parser for the chat-completions stream. * @module dsh-llm-deepseek/sse */ diff --git a/packages/llm/llm-deepseek/src/translate.ts b/packages/llm/llm-deepseek/src/translate.ts index 27d2ac3d1a..c66271246c 100644 --- a/packages/llm/llm-deepseek/src/translate.ts +++ b/packages/llm/llm-deepseek/src/translate.ts @@ -1,4 +1,9 @@ /** + * Translate DeepSeek SSE payloads with one stateful harness block per content, reasoning, or tool + * call index. An empty initial reasoning delta does not open a block. Finish reason and the latest + * usage are deferred until `[DONE]`, covering both finish-attached and trailing usage-only shapes + * while ensuring no chunk follows `finish`. + * * Translate DeepSeek wire chunks into the harness `StreamChunk` protocol. * @module dsh-llm-deepseek/translate */ diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 3e533f8e7c..5944f8d30a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -187,7 +187,7 @@ describe('serializeRequest', () => { }) }) -describe('review fixes: assistant content shapes', () => { +describe('assistant empty and tool-call content shapes', () => { it('serializes a content-less, tool-call-less assistant message as null content', () => { // Aborted/empty assistant turns: no text, no calls → null (the wire // accepts it; "" is reserved for tool-call turns per the samples). diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 65fcc84cbc..4b93b5e87d 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -1,6 +1,6 @@ /** - * `PiAiAdapter`: the `@earendil-works/pi-ai`-backed implementation of the harness LLM seam, - * pointed at a DeepSeek (OpenAI-compatible) endpoint. + * Pi-ai-backed DeepSeek adapter and design twin of the hand-rolled adapter. + * Both implementations must fit the same provider-neutral stream vocabulary. * @module dsh-llm-pi-ai/adapter */ @@ -37,8 +37,8 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model< api: 'openai-completions', provider: 'deepseek', baseUrl: options.baseURL, - // Always true: pi-ai only emits the DeepSeek `thinking` field for reasoning-capable models, - // deriving enabled/disabled from whether a reasoningEffort option is passed. + // Keep reasoning support enabled so `off` can send DeepSeek's explicit + // disabled marker rather than falling back to the provider's enabled default. reasoning: true, // DeepSeek's official effort levels: high|max (xhigh maps to max). thinkingLevelMap: { minimal: null, low: null, medium: null, high: 'high', xhigh: 'max' }, @@ -143,8 +143,8 @@ export class PiAiAdapter extends LlmAdapter { // `reasoning_effort` so the provider chooses its default effort. const reasoning = this.options.reasoning ?? 'high' - // pi-ai's event stream has no iterator-return cancellation hook: if our consumer stops - // early (break / loop abort), the underlying HTTP stream would keep draining. + // Pi-ai has no iterator-return cancellation hook. Chain an internal signal + // and abort it when this generator exits so early consumers stop the HTTP stream. const controller = new AbortController() const onCallerAbort = (): void => { controller.abort(options.signal?.reason) } if (options.signal?.aborted) controller.abort(options.signal.reason) diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 8b409fad56..098b93edb3 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -1,7 +1,10 @@ /** * Bidirectional mapping between the harness vocabulary and pi-ai's: - * `GenerateOptions`/`Message[]` → pi-ai `Context`, and pi-ai `AssistantMessageEvent`s → - * harness `StreamChunk`s. + * Convert harness requests to pi-ai context and pi-ai assistant events to harness stream chunks. + * pi-ai parses tool arguments while the harness preserves raw JSON, so conversion parses inbound + * arguments and re-stringifies outbound values while the adapter restores provider payloads. + * In-stream pi-ai errors become harness error/aborted finishes, and its reasoning tokens remain + * folded into output usage because it reports no separate count. * @module dsh-llm-pi-ai/convert */ @@ -65,7 +68,8 @@ export function toPiContext(options: GenerateOptions): PiContext { content.push({ type: 'text', text: block.text }) break case 'reasoning': - // thinkingSignature names the wire field pi-ai replays the CoT under. + // Without this wire-field name, pi-ai replays an empty `reasoning_content`, violating + // DeepSeek's thinking-mode passback rule on tool-call turns. content.push({ type: 'thinking', thinking: block.text, thinkingSignature: 'reasoning_content' }) break case 'tool-call': diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cefaa9f745..f3f12666a3 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -312,7 +312,7 @@ describe('buildModel', () => { }) }) -describe('review fixes', () => { +describe('provider reasoning, passback, and early-stream cancellation', () => { it('defaults omitted reasoning config to thinking ENABLED (provider default)', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) // no reasoning key at all @@ -375,7 +375,7 @@ describe('review fixes', () => { }) }) -describe('review fixes: abort wiring', () => { +describe('caller cancellation', () => { it('honors a pre-aborted caller signal', async () => { const ctx = await harness('http://127.0.0.1:1') const controller = new AbortController() diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 2e6ecd14c7..1c0f8df41b 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -46,4 +46,4 @@ Every product adapter sends application identity on provider HTTP requests. `att ### Real adapters -Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths). +Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) uses hand-rolled fetch/SSE, while [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) uses `@earendil-works/pi-ai`. Both follow the `StreamChunk` conventions in `types.ts`: usage precedes finish, tool arguments remain raw strings, and errors take one of two sanctioned paths. See [the twin LLM adapters](../../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the design rationale. diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 680512ffdc..163bb5679a 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -73,8 +73,8 @@ export class BlockAssembler { } case 'block-end': { const partial = this.ensure(chunk.index, chunk.block.type) - // First close wins: a second block-end for an already-closed index is a straggler (same - // rule as post-close deltas). + // First close wins; ignoring re-close stragglers keeps streamed output + // and the final assembled block in agreement. if (partial.block) return partial.block = chunk.block return chunk.block diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index f305646683..8f0f156aa1 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -1,4 +1,8 @@ /** + * Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping + * adapters from drifting. See + * `docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`. + * * App-attribution vocabulary for provider requests. * @module @deepseek-ai/dsh-llm/attribution */ diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index cdfff245a8..aa4c871e2f 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,5 +1,8 @@ /** - * The call configuration of a conversation and its comparison/freeze utilities. + * Conversation call configuration and freeze utilities. Model and sampling + * values are request-header state that can affect cache reuse; request + * waterfalls replace them and the loop logs changes instead of allowing + * silent per-call drift. * @module dsh-llm/call-config */ @@ -30,9 +33,9 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { } /** - * Deep-freeze a value in place so any later mutation throws (ESM code runs in strict mode), - * and return it. - * + * Deep-freeze a value in place, guarding cycles, so later mutation throws. + * {@link AbortSignal} objects are deliberately skipped because they are the + * request's live cancellation channel and freezing them breaks abort. * @param value - the value to freeze in place. * @returns the same value, frozen. */ diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index 9d523577b1..c1fdbb9ffa 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -1,6 +1,6 @@ /** - * The harness error taxonomy: one base class so failures carry a stable, machine-routable - * `code` and chain their `cause`, instead of flattening to a bare message string. + * Harness error base with a stable machine-routable code and chained cause. + * Package errors extend it so tool results and replay can retain failure class. * @module @deepseek-ai/dsh-llm/error */ diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f457cffa10..08f3f54c51 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -54,7 +54,10 @@ export class LlmError extends HarnessError { } /** - * Base class for LLM provider adapters. + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(models, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. */ export abstract class LlmAdapter { /** diff --git a/packages/llm/llm/src/never.ts b/packages/llm/llm/src/never.ts index f25a56ec32..e50a7478df 100644 --- a/packages/llm/llm/src/never.ts +++ b/packages/llm/llm/src/never.ts @@ -1,11 +1,14 @@ /** - * Exhaustiveness helper for switches over core unions. + * Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a + * new variant fails compilation at every required handler. Do not use it for declaration-merged + * unions such as session events or content blocks: handle known variants and explicitly fall + * through because plugins may add valid unknown cases. * @module @deepseek-ai/dsh-llm/never */ /** - * Mark an unreachable closed-union branch and diagnose values that escaped - * static exhaustiveness. + * Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site; + * a value that escaped its type throws with diagnostics at runtime. * @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site. * @param context - optional label (e.g. the switch site) prefixed into the throw message. * @returns never — it always throws, with the offending value JSON-rendered in the message. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4b28d0a9fc..8f5fcf0d1e 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -1,5 +1,7 @@ /** - * Provider-neutral message and streaming vocabulary. + * Canonical provider-neutral message and streaming vocabulary for the loop, + * session log, and plugins. Adapters alone translate provider wire shapes; + * mapped interfaces make the content, source, and finish unions extensible. */ import type { Branded } from '@deepseek-ai/dsh-brand' @@ -102,6 +104,10 @@ export interface TokenUsage { /** * Raw streaming protocol emitted by adapters. + * Block indexes correlate interleaved deltas, and `block-end` carries the + * assembled block. Adapters emit usage before the terminal finish and nothing + * afterward; tool arguments remain raw JSON strings. Failures either throw or + * end with `error`/`aborted`, and consumers must handle both paths. */ export type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } @@ -150,8 +156,8 @@ export interface GenerateOptions { stop?: string[] signal?: AbortSignal /** - * The id of the session this request belongs to — stamped by the agent loop from - * `agent.session.id`. + * Session identity stamped by the loop for listener routing. Adapters ignore + * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 9cb4b69d8a..5612e93cb4 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -63,8 +63,8 @@ describe('BlockAssembler', () => { it('throws from assemble() when a partial has an unhandled blockType', () => { const assembler = new BlockAssembler() - // A partial whose blockType is not text/reasoning/tool-call cannot be assembled without its - // block-end. + // Unknown declaration-merged block types cannot be assembled from partial deltas. Opening a + // plugin-added `video` block without its required `block-end` exercises that failure. assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk) expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"') }) @@ -133,10 +133,9 @@ describe('assertNever', () => { }) }) -describe('BlockAssembler regressions (property-test findings)', () => { +describe('BlockAssembler duplicate-close contract', () => { it('first block-end wins: a duplicate block-end for a closed index is ignored', () => { - // Found by fast-check (the property-testing RFC): two block-ends at the same index made the - // streamed prefix (first block) disagree with final blocks() (second block). + // The first close wins so streamed and final output cannot disagree. const chunks: StreamChunk[] = [ { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index faa7e2fafa..0565dae39f 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -10,6 +10,8 @@ The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. +Each rung has a self-skipping keyless world-effect test; CI runs platform legs against real kernels and rejects a silent all-skip. The packed-install test exercises the registry launcher and executable mode through a plain-Node consumer. + ```yaml - id: sandbox name: '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 284496e30a..cc464eb5c4 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -1,5 +1,8 @@ /** - * `LocalSandboxProvider`: the local implementation of the `@deepseek-ai/dsh-sandbox` seam. + * Local sandbox backend. It selects the platform runner chain (Linux bwrap then + * Landlock; macOS Seatbelt), functionally probes competing candidates once, and + * reports each wrap's enforcement and stderr dialects. Missing or unusable + * confinement fails closed rather than returning the original argv. * @module @deepseek-ai/dsh-sandbox-local */ @@ -16,7 +19,10 @@ import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPoli /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** - * Override the sandbox runner argv (the bwrap-shaped profile arguments are appended). + * Override the runner argv; bwrap-shaped profile arguments are appended. A + * non-empty override asserts full enforcement and skips built-in selection and + * probing; a broken runner then fails at execution and must be identifiable by + * {@link runnerFailureSignatures}. */ runnerCommand?: string[] /** @@ -33,7 +39,9 @@ export interface Config { } /** - * The `bwrap` profile arguments for one policy. + * Build a bwrap profile: the host is read-only with fresh `/dev` and `/proc`; + * workspace-write overlays writable temp and workspace mounts. PID and network + * isolation are intentionally outside the file-effect policy. * * @param policy - the file-effect policy to express as bwrap arguments. * @returns the bwrap profile arguments (before the trailing `--` + argv). @@ -48,9 +56,9 @@ export function bwrapProfileArgs(policy: SandboxPolicy): string[] { } /** - * The `landlock-run` grant arguments for one policy — the bwrap profile's file-effect - * semantics expressed as a Landlock allow-list (Landlock cannot mount, so there are no - * fresh/ephemeral filesystems). + * Build Landlock grants for the same file policy without synthetic mounts. + * Read-only grants only `/dev/null` for writes; workspace-write also grants the + * host temp root and workspace. * * @param policy - the file-effect policy to express as launcher grants. * @returns the launcher grant arguments (before `--` + argv). @@ -74,7 +82,7 @@ function canonicalPath(path: string): string { try { return realpathSync(path) } catch { - // realpathSync failed: the path (or a prefix) is missing or unreadable. + // An unresolved grant matches nothing until the named path exists; keep its spelling. return path } } @@ -85,11 +93,9 @@ function sbplString(path: string): string { } /** - * The `sandbox-exec` arguments for one policy: `-p` plus a Seatbelt (SBPL) profile with the - * same file-effect semantics as the other dialects, built as allow-default → `(deny - * file-write*)` → write allow-list (later rules win), so exactly the mode's promised file - * effects are governed — network and process visibility stay unrestricted, which is all the - * seam's mode vocabulary claims. + * Build a Seatbelt profile that denies file writes then allows `/dev/null` and, + * for workspace-write, the canonical workspace, host temp, and per-user macOS + * temp roots. Network and process visibility remain unrestricted. * * @param policy - the file-effect policy to express as an SBPL profile. * @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv). @@ -208,11 +214,9 @@ const DENIAL_SIGNATURES = { } as const satisfies Record /** - * How each runner's own failure identifies itself on stderr (the seam's - * `ConfinedArgv.runnerFailureSignatures`): every runner prefixes its error lines with its - * program name, and the shell's runner-not-found message carries the same `name: ` shape - * (`bash: bwrap: command not found`, `bash: …/bin/landlock-run: No such file or directory`) — - * so one substring per runner covers both "runner broke" and "runner missing". + * Runner-owned stderr prefixes cover both internal refusal and shell-level + * not-found errors. Consumers match these before denial text because the + * command never ran on this path. */ const RUNNER_FAILURE_SIGNATURES = { bwrap: ['bwrap: '], @@ -330,7 +334,7 @@ export class LocalSandboxProvider extends SandboxProvider { const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? [] const [first, ...rest] = chain if (first === undefined) return 'unavailable' - // One candidate = nothing to arbitrate: select it without probing. + // A sole candidate needs no arbitration; its execution-time refusal still fails closed. if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] } for (const runner of chain) { const enforcement = this.probeRunner(runner) diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index 2102fac947..1e818e1bdf 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -9,8 +9,11 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * Keyless bwrap integration proof for the backend: the real `bwrap` confining real processes - * through `confine()` + a direct spawn of the returned argv. + * Keyless backend integration through `confine()` and a real bwrap process. With no rung forced, + * a passing probe must select the first rung. Tests assert world effects, wrap shape, and that the + * kernel denial matches the advertised dialect; consumer coverage lives in dsh-bash-sandbox. + * Skips when bwrap or user namespaces are unavailable. HOME-based workspaces avoid bwrap's + * ephemeral `/tmp`, so workspace-write actually proves the workspace-root rebind. */ const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index 166760ef3f..f5ecbc67f9 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -10,10 +10,10 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** - * Keyless Landlock integration proof for the backend: the real npm-distributed `landlock-run` - * launcher (`node-addon-landlock-run`) confining real processes through `confine()` + a direct - * spawn of the returned argv, with the bwrap rung forced off so the ladder lands on the - * launcher. + * Keyless backend integration through `confine()` and the registry `landlock-run` launcher, with + * bwrap forced off. Tests assert real world effects; consumer coverage lives in dsh-bash-sandbox. + * Skips when the platform package or enforcing kernel is unavailable. HOME-based workspaces avoid + * Landlock's wholesale `/tmp` grant, so workspace-write proves the workspace-root grant itself. */ const probe = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, encoding: 'utf8' }) diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 2e2acc55aa..c221544861 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -259,7 +259,8 @@ describe('the platform chains', () => { }) it('a rogue cached runner tag throws via the exhaustiveness guard (closed union)', async () => { - // A rogue runner tag must hit assertNever. + // Only a cast can create this rogue closed-union tag. It must hit `assertNever`, ensuring a new + // runner cannot silently use another runner's wrap or denial dialect. const { sandbox } = await setup() ;(sandbox as unknown as { selectedRunner: unknown }).selectedRunner = { runner: 'chroot', enforcement: 'full' } expect(() => sandbox.confine(['true'], RO)).toThrow('unreachable variant') diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 8d90d1668d..62cb56dc31 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -6,7 +6,16 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -/** Keyless packed-tarball smoke in an external plain-Node consumer. */ +/** + * Keyless publish-path rehearsal. It packs the package and workspace peers, installs those exact + * tarballs in an external plain-Node consumer, and lets npm resolve the registry Landlock launcher + * plus its platform package. No tsx, path mapping, or workspace resolution can hide missing files, + * dependency errors, or lost executable modes. + * + * The installed launcher must match the host architecture, remain executable, and either confine a + * real process with bwrap disabled or fail closed on a non-enforcing kernel. Skips off Linux or + * before `pnpm run build`; launcher byte provenance belongs to its upstream release pipeline. + */ const packageDir = fileURLToPath(new URL('..', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url)) @@ -59,7 +68,8 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- tarballs.push(lines[lines.length - 1] as string) } - // Install packed tarballs in a plain ESM consumer, including optional platform dependencies. + // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], { cwd: consumerDir, diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index d2742bb65d..2c5a026ddb 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -9,9 +9,11 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local' /** - * Keyless Seatbelt integration proof for the backend: the real macOS `sandbox-exec` confining - * real processes through `confine()` + a direct spawn of the returned argv, with the Linux - * rungs forced off so the ladder lands on Seatbelt. + * Keyless backend integration through `confine()` and a real macOS Seatbelt process, with Linux + * rungs forced off. Tests assert world effects and that the kernel denial matches the advertised + * dialect; consumer coverage lives in dsh-bash-sandbox. Skips off macOS or when the profile probe + * fails. HOME-based workspaces avoid Seatbelt's wholesale temp-directory grants, so + * workspace-write proves the workspace-root grant itself. */ const probe = spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }) diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 68f870c0a2..563120283d 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -1,7 +1,7 @@ /** - * The process-sandbox seam (`ctx.sandbox`): an abstract service defining what platform - * confinement does — wrap a subprocess argv so it executes under a file-effect policy — - * without saying how. + * Same-world process-confinement seam: wrap exact subprocess argv under a + * host-path file policy. Containers, microVMs, and remote execution replace the + * surrounding capability seam instead; this service shares the host kernel and filesystem. * @module @deepseek-ai/dsh-sandbox */ @@ -9,7 +9,10 @@ import { Context, Service } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' /** - * File-effect policy a sandbox backend enforces on confined processes. + * File-effect policy for confined processes. `read-only` permits only required + * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a + * backend-defined temp area; `danger-full-access` bypasses confinement. Network + * and process visibility are outside this vocabulary. */ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' @@ -17,7 +20,9 @@ export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' export type ConfinedSandboxMode = Exclude /** - * How completely the selected backend enforces a confined mode's file effects. + * Enforcement completeness for this host. `partial` means an active backend or + * older kernel ABI cannot govern every promised file effect; callers requiring + * an absolute boundary must not treat it as `full`. */ export type SandboxEnforcement = 'full' | 'partial' @@ -57,10 +62,9 @@ export interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr substrings - * produced when the sandbox binary is missing, refuses its profile, or fails closed before - * exec'ing the command (`bwrap: `, `landlock-run: `, `sandbox-exec: ` — each covers both the - * runner's own error prefix and the shell's runner-not-found message). + * Case-insensitive signatures for runner failure before command execution. + * Consumers check these before denial signatures: runner failure means the + * command never ran, while denial means confinement worked and blocked it. */ runnerFailureSignatures: readonly string[] } @@ -102,9 +106,10 @@ declare module 'cordis' { } /** - * Abstract process-sandbox service. Subclass, implement {@link confine}, and load the subclass - * as a plugin — it registers as `ctx.sandbox` (one implementation per context; loading a - * second throws, cordis' standard duplicate-service behavior). + * Abstract process-sandbox service. {@link confine} must return enforcing argv + * or fail closed at wrap or runner-execution time; silent unconfined passthrough + * is forbidden. Functional probes arbitrate multi-runner chains and may be + * skipped for a sole candidate, whose own refusal remains the fail-closed end. */ export abstract class SandboxProvider extends Service { constructor(ctx: Context) { diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 013d757736..39bdecf751 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -75,6 +75,9 @@ function isHeaderLine(value: unknown): value is HeaderLine { * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16) * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string, * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use. + * Safe code units remain literal; every other unit, including `~`, becomes + * `~XXXX`. Operating on code units preserves lone surrogates, while special- + * casing `.` and `..` prevents traversal by an otherwise safe whole segment. * * @param raw - the string to encode; must be non-empty (throws on `''`). * @returns the escaped single path segment, decodable back to `raw`. @@ -132,9 +135,10 @@ export function eventLine(event: SessionEvent): string { } /** - * Parse a JSONL log buffer into its preserved event prefix (the header is line 0). Returns the - * longest prefix of complete, seq-contiguous events plus the byte offset of the end of the - * last preserved line (`committedBytes`). + * Parse a JSONL log buffer into its preserved event prefix (the header is line + * 0). Fully written events in an interrupted final turn remain part of the + * prefix. The first unparsable record or seq gap after the last `turn/end` + * marks a tolerated torn tail; the same hole in the committed region rejects. * * @param buffer - the raw bytes of the log file (header line first). * @returns the header, the preserved event prefix, and `committedBytes` — the @@ -142,9 +146,8 @@ export function eventLine(event: SessionEvent): string { */ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } { const text = buffer.toString('utf8') - // Split into complete (newline-terminated) lines, tracking the byte offset of each line's end - // so the truncation point is exact (multi-byte chars make the char offset differ from the - // byte offset). + // Track complete lines by byte offset: a non-newline tail is torn and ignored, + // and a running counter avoids rescanning a long multi-byte log. const lines: { text: string; endByte: number }[] = [] let start = 0 let byteOffset = 0 @@ -172,8 +175,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE } const headerLine = parsedHeader - // Find the committed region: the prefix up to and including the LAST complete `turn/end` in - // the WHOLE log. + // Parse every complete record first so the last valid `turn/end` determines + // whether an earlier hole is committed corruption or an uncommitted tail. interface Parsed { ok: boolean; event?: SessionEvent; endByte: number } const parsed: Parsed[] = eventEntries.map((entry) => { try { @@ -191,8 +194,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE if (p?.ok && p.event?.type === 'turn/end') { lastTurnEnd = i; break } } - // Walk the longest PREFIX of complete, seq-contiguous, parseable event lines (line i is a - // parsed event with seq === i). + // Preserve the contiguous prefix, including a complete interrupted turn; + // holes through the last committed boundary throw, while later holes stop. const preserved: SessionEvent[] = [] for (let i = 0; i < parsed.length; i++) { const p = parsed[i] diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 82522228f6..4bb2d574f7 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -1,5 +1,7 @@ /** - * JSONL durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-jsonl`). + * JSONL durable session-persistence backend. It stores a header and contiguous + * events in one append-only file per session, and delegates orchestration to + * {@link PersistenceCoordinator}. * @module @deepseek-ai/dsh-session-persistence-jsonl */ @@ -66,7 +68,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi constructor(ctx: Context, public config: Config) { super(ctx) - // Resolve the configured root to an ABSOLUTE path ONCE, here. + // Resolve once so later process.cwd() changes cannot split one backend across roots. this.root = resolve(config.root) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -85,8 +87,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.load(id) } - // `list` is BOTH the public service method and the PersistenceBackend hook — one method, the - // bucket walk below. + // One method serves both public `list` and the backend hook; delegating it to + // the coordinator would call this hook recursively. /** * The per-session init promises, exposed for white-box tests that await a @@ -181,8 +183,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await mkdir(dir, { recursive: true, mode: 0o700 }) await this.syncDir(this.root) const finalPath = logPath(this.root, meta.cwd, meta.id) - // Never rename over an existing committed log: materialize is the FIRST write of a session - // the backend believes is new. + // Materialization is the first write; an existing log is an id collision. /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */ if (await this.exists(finalPath)) { throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`) @@ -207,8 +208,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi await link(tmp, finalPath) linked = true } finally { - // If link failed, the temp is the only reference and must be removed before the original - // error propagates. + // Remove an unpublished temp on failure. After publication, defer cleanup + // until the directory entry is durable so cleanup cannot reject a live log. /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */ if (!linked) await rm(tmp, { force: true }) } @@ -326,7 +327,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi const entries = await readdir(this.root, { withFileTypes: true }) return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`) } catch (error) { - // ENOENT = the root has not been created yet → genuinely no sessions. + // Only an absent root means no sessions; rethrow every other I/O failure. if (isENOENT(error)) return [] throw error } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 19b3d9cd96..a4f0334f50 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -54,7 +54,8 @@ runPersistenceContract('jsonl', async () => { } }) -// Run the shared coordinator orchestration suite against the real JSONL backend. +// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial, +// newline-less fragment past the committed region so coordinator repair runs on real file bytes. runCoordinatorContract('jsonl', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-')) return { @@ -344,7 +345,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => { ].join('\n') + '\n' // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and - // stops at the gap. + // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0]) }) @@ -464,7 +465,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('list reads a header line longer than the 8KB read chunk', async () => { - // readFirstLine accumulates across reads when the first line exceeds its buffer. + // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving + // `readFirstLine` accumulates chunks before `list()` parses it. const bucket = join(root, '_no-cwd') await mkdir(bucket, { recursive: true }) const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) }) @@ -484,7 +486,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s) await sessFiberA.dispose() - // A NEW live Session object reuses id "reuse". + // A new Session object reuses the id. Object-keyed initialization must run independently, + // detect the disk collision, and reject instead of appending through session A's stale cursor. const backend = ctx.sessionPersistence as unknown as { inits: Map> } let b!: Session await ctx.plugin(Object.assign((inner: Context) => { @@ -502,7 +505,9 @@ describe('SessionPersistenceJsonl: edge cases', () => { await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog()) await ctx.fiber.dispose() - // Backend 2 over the same root. + // Backend 2 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id, + // undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead + // of grafting no-cwd events onto a log with mismatched cwd. const ctx2 = new Context() await ctx2.plugin(SessionStore) await ctx2.plugin(SessionPersistenceJsonl, { root }) @@ -570,7 +575,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => { - // A durable backend must not collapse a storage fault to "no sessions". + // A durable backend must not collapse a storage fault to "no sessions". Making the root a + // regular file forces ENOTDIR from `readdir`, which must propagate. const filePath = join(root, 'not-a-dir') await writeFile(filePath, 'x') const ctx2 = new Context() @@ -581,8 +587,8 @@ describe('SessionPersistenceJsonl: edge cases', () => { }) it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => { - // A non-ENOENT error from the per-id open() must surface, not be collapsed to "not found" - // (which would let live-adoption proceed under a false absence assumption). + // A non-ENOENT per-id open error must surface rather than become "not found" and permit false + // live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path. const cwd = '/x' const ctx2 = new Context() await ctx2.plugin(SessionStore) @@ -703,7 +709,7 @@ describe('SessionPersistenceJsonl: edge cases', () => { const session = ctx.sessions.create(SessionId('reject-bad')) // Serializability is enforced at the source: Session.append throws on a BigInt-bearing // event before it enters session.events, so the durable log can never diverge from the live - // log. + // log. The error therefore surfaces synchronously at append, not later during backend flush. expect(() => { session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' }) }).toThrow(/non-JSON-serializable/) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index f9d719657a..32b8f4ade8 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -1,5 +1,7 @@ /** - * SQLite durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`). + * SQLite durable session-persistence backend. It maps each session header and + * event to rows, and delegates write-path orchestration to + * {@link PersistenceCoordinator}. * @module @deepseek-ai/dsh-session-persistence-sqlite */ @@ -75,8 +77,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers constructor(ctx: Context, public config: Config) { super(ctx) - // Open the database asynchronously (the parent directory may need creating); every hook - // awaits `ready` first. + // Open asynchronously so directory creation does not block plugin apply; + // every storage hook awaits the same readiness promise. this.ready = this.openDb(config.path, (config as Required).journalMode) this.coordinator = new PersistenceCoordinator(this.ctx, this) } @@ -105,8 +107,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.load(id) } - // `list` is BOTH the public service method and the PersistenceBackend hook — one method (the - // SELECT below). + // One method serves both public `list` and the backend hook; delegating it to + // the coordinator would call this hook recursively. /** * The per-session init promises, exposed for white-box tests that await a diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 23bdc1c7ed..adb23cbb43 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -56,7 +56,9 @@ export interface EventRow { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' /** - * Open the database, validate its version, and apply schema and pragmas. + * Open the database and apply its schema and pragmas. A zero `user_version` is + * stamped with {@link SCHEMA_VERSION}; every other non-current version rejects + * rather than being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. * @returns the open handle with pragmas applied and both tables ensured. @@ -140,9 +142,10 @@ export function rowToEvent(row: EventRow): SessionEvent { } /** - * The preserved prefix of an ordered event-row list (mirrors the JSONL backend's `scanLog`): - * the longest prefix of complete, seq-contiguous, parseable rows, PLUS the seq from which a - * never-committed torn tail must be deleted (or `undefined` if the whole list is intact). + * Find the preserved prefix of ordered event rows. Fully written rows in an + * interrupted final turn remain in the prefix. The first unparsable row or seq + * gap after the last `turn/end` marks a tolerated torn tail; the same hole in + * the committed region rejects. * * @param rows - one session's event rows, ordered by seq ascending. * @returns the preserved event prefix, plus `tornFrom` — the seq the physical @@ -167,7 +170,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break } } - // Walk the longest PREFIX of complete, seq-contiguous, parseable rows (row i has seq === i). + // Preserve the contiguous prefix, including a complete interrupted turn; + // holes through the last committed boundary throw, while later holes stop. const preserved: SessionEvent[] = [] for (let i = 0; i < rows.length; i++) { const p = parsed[i] diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 4191c40995..e3429e5835 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -28,8 +28,7 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () = return { ctx, dispose: () => fiber.dispose() } } -// The payoff: the SAME backend-agnostic contract the JSONL backend runs, now -// proving the SQLite backend satisfies identical semantics. +// Run the same backend-agnostic contract as JSONL to pin identical semantics. runPersistenceContract('sqlite', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -40,7 +39,8 @@ runPersistenceContract('sqlite', async () => { } }) -// Run the shared coordinator orchestration suite against the real SQLite backend. +// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid +// JSON past the committed seq, exercising coordinator repair against real database rows. runCoordinatorContract('sqlite', async (): Promise => { const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-')) const path = join(dir, 'sessions.db') @@ -63,7 +63,8 @@ runCoordinatorContract('sqlite', async (): Promise => { describe('scanRows', () => { // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents - // so the unit tests read in terms of the event vocabulary. + // so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to + // its nullable columns so the conversion remains faithful. const rows = (events: SessionEvent[]): EventRow[] => events.map((e) => { const se = e as SessionEvent @@ -252,7 +253,8 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { // Two unmerged branches each shipped a DISTINCT layout under user_version 3 (one added only - // `seed_length`, the other only the surface columns). + // `seed_length`, the other only the surface columns). The merged v4 cannot interpret that + // ambiguous, incomplete layout and must reject it. const path = await freshDbPath() openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) const db = openDatabase(path, 'wal') @@ -269,7 +271,9 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5 await b1.dispose() - // Hand-insert a torn tail row (seq 6, no closing turn/end) whose `data` is invalid JSON. + // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary + // from seq/type columns without parsing the tail, preserves the committed prefix, and load + // deletes the row; invalid JSON inside the committed region would remain fatal. const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', '{not valid json') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index bf7da03757..b0c2202b8c 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -22,9 +22,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l ## The write coordinator -The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). - -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). The `PersistenceBackend` hooks (the only seam between the coordinator and storage): diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index d7bec68c9f..cbdb9ba25f 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -1,6 +1,7 @@ /** * Shared buffering, serialization, adoption, repair, and disposal orchestration - * over backend-specific persistence primitives. + * for first-party backends. Third-party backends may implement the public + * persistence seam directly. * @module @deepseek-ai/dsh-session-persistence/coordinator */ @@ -84,7 +85,11 @@ interface SessionState { meta: SessionHeader /** The next seq the backend expects to append (the stored log length). */ cursor: number - /** Whether lazy creation has produced a durable artifact. */ + /** + * Whether lazy creation has produced a durable artifact. The first append + * atomically materializes the header with events; reclaim logic uses this to + * distinguish an unused id from a persisted collision. + */ materialized: boolean /** * The live Session this state was bound to via `onCreated`, if any. State diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 53a0feebcf..87bbdde129 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -1,6 +1,7 @@ /** - * Durable session-persistence seam. Backends store {@link SessionEvent}s plus - * separate {@link SessionHeader} metadata. + * Durable session-persistence seam (`ctx.sessionPersistence`). Backends store + * {@link SessionEvent}s as the event-sourced log and carry non-replayable + * {@link SessionHeader} metadata separately. * @module @deepseek-ai/dsh-session-persistence */ @@ -83,9 +84,9 @@ export abstract class SessionPersistence extends Service { /** * Load a header and balanced contiguous log. A complete interrupted final - * turn is preserved and closed with missing tool errors and boundary events; - * only a torn final record is discarded. Unknown versions and corruption in - * the committed prefix reject. + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 96fd0c66f7..d8cd9fc230 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -43,8 +43,9 @@ export function oneTurnLog(): SessionEvent[] { } /** - * Append a whole event log to a LIVE session, event by event, forwarding the surface metadata - * each event already carries. + * Append recorded events to a live session while forwarding surface metadata verbatim. The broad + * `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a + * surface event whose fixture omitted it; this helper never synthesizes a default. */ export function appendLog(session: Session, events: readonly SessionEvent[]): void { for (const e of events) { @@ -217,7 +218,8 @@ export function runPersistenceContract(name: string, make: () => Promise = { type: 'text', text: 'x' } cyclic['self'] = cyclic const badValues: unknown[] = [ diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 51a4cc9ecb..ffe5c7f6bd 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -1,5 +1,11 @@ /** - * Reusable ORCHESTRATION suite for any backend that composes a {@link PersistenceCoordinator}. + * Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}. + * Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring, + * lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload, + * flush, and disposal quiescence through public APIs rather than storage primitives. + * + * Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs + * retain only storage-mechanics tests, while these scenarios run once per backend. * @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract */ @@ -16,10 +22,13 @@ import { meta, oneTurnLog, appendLog } from './contract.ts' * the suite mounts/disposes backend instances on it and cleans it up at the end. */ export interface CoordinatorFixture { - /** Mount a backend over shared fixture storage and return its disposable fiber. */ + /** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */ mount: (ctx: Context) => Promise - /** Inject an uncommitted torn tail; absent for backends that cannot produce one. */ + /** + * Inject a never-committed partial record after the durable region so `loadCore` reaches + * `commitRepair`. Omit only when the backend structurally cannot produce torn tails. + */ corruptTail?: (id: SessionId, cwd: string | undefined) => Promise /** Tear down the storage scope (remove the temp dir / file). */ @@ -87,7 +96,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the seed; the // boundary must survive a reload (so a resume/replay can tell the inherited prefix from - // the child's own events). + // the child's own events). JSONL stores it in the header; SQLite uses `seed_length`. const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { @@ -173,10 +182,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< }) it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => { + // Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation. const fix = await makeFixture() const first = await freshCtx(fix) try { - // First lifecycle: persist a session through the store. const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } }) send(s1, oneTurnLog()) await first.ctx.parallel('session/flush', s1) @@ -184,9 +193,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await first.fiber.dispose() } - // Second lifecycle: a NEW backend instance + a session re-created with the - // same id SEEDED with the loaded events. onCreated adopts the stored log - // (does not re-persist the seed); a new turn appends at seq 6. const second = await freshCtx(fix) try { const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) @@ -197,7 +203,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await second.ctx.parallel('session/flush', s2) const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed')) - // 6 original + 2 new, contiguous, no duplicated seed. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) } finally { await second.fiber.dispose() @@ -265,7 +270,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< await ctx.parallel('session/flush', session) // Hot-reload: dispose instance 1, mount instance 2 over the same storage while the - // session stays live. + // session stays live. The new instance has no coordinator state but must adopt the + // materialized prefix, then persist another turn rather than rejecting it as a collision. await backend1.dispose() await fix.mount(ctx) session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -358,7 +364,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } // A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated - // treats it as new; create() rejects because a log already exists. + // treats it as new; create() rejects because a log already exists, and `flush()` surfaces + // that initialization rejection. const second = await freshCtx(fix) try { const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } }) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 893318fbf2..13943d35a7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -16,8 +16,10 @@ type MemoryStore = Map interface MemoryConfig { store?: MemoryStore } /** - * A trivial in-memory {@link SessionPersistence} that composes a {@link - * PersistenceCoordinator} over a dependency-free `Map`-backed {@link PersistenceBackend}. + * Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a + * dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple + * instances share materialized sessions, the in-memory analogue of reload over one file/database; + * durable behavior is covered by the JSONL and SQLite backends. */ class MemoryPersistence extends SessionPersistence implements PersistenceBackend { static inject = ['sessions'] @@ -78,8 +80,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend } const existing = this.store.get(m.id) if (!existing) { - // First batch: `_isMaterialized` is false (the coordinator only omits - // materialization on the first batch); writing the entry IS the materialization. + // The coordinator sends the first batch for materialization; later batches append. this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] }) } else { existing.events.push(...structuredClone(events) as SessionEvent[]) @@ -112,7 +113,8 @@ runPersistenceContract('memory', async () => { } }) -// Run the shared coordinator orchestration suite against the in-memory backend. +// Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are +// atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { const store: MemoryStore = new Map() return { diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index c885416ff5..4b661d45b0 100644 --- a/packages/skill/skill-local/README.md +++ b/packages/skill/skill-local/README.md @@ -28,7 +28,7 @@ Default roots are resolved in this provider's rank order: | 400 | `user-dsh` | `/skills` | | 500 | `user-agents` | `/skills` | -The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. DeepSeek Harness no longer ships built-in system skills from this provider; additional built-ins can be supplied later by another provider. +The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills. When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request. diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index 1d2513203b..7523f562a5 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -175,8 +175,8 @@ export class SkillService extends Service { } /** - * Register a borrowed same-process provider. Duplicate and reserved names - * throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters * the provider and invalidates catalog caches. * @param provider - the provider to register by `provider.name`. * @returns the exact Cordis effect disposer that unregisters this provider; @@ -210,11 +210,11 @@ export class SkillService extends Service { } /** - * Register a borrowed readonly runtime skill. Project entries outrank runtime - * entries, which outrank user entries. A duplicate is ignored with a no-op - * disposer so it cannot remove the first registration. + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. * @param skill - the complete skill definition to expose for discovery. - * @returns the exact Cordis disposer, which also invalidates caches. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. */ register(skill: SkillRegistration): () => void { validateRuntimeSkill(skill) @@ -256,8 +256,9 @@ export class SkillService extends Service { } /** - * Load and validate the winning provider candidate. Cancellation is checked - * after selection and raced against provider loading. + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. * @param name - kebab-case skill name. * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. * @returns the full skill, including body content, or `undefined`. diff --git a/packages/skill/tool-skill/README.md b/packages/skill/tool-skill/README.md index 7e72f89000..aa5f669924 100644 --- a/packages/skill/tool-skill/README.md +++ b/packages/skill/tool-skill/README.md @@ -16,6 +16,10 @@ The plugin contributes one user-role `` catalog through `agent/ |---|---|---| | `name` | string (required) | Exact kebab-case skill name from the available skills listing. | -Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers can resolve the right winning skill. A successful call returns one text tool result with ``, containing `` followed by ``. Resource guidance resolves paths or URLs explicitly referenced by the loaded instructions against `resourceBase`; referenced scripts, references, and assets load only when needed, and the tool does not enumerate a skill directory. Local filesystem skills provide a base directory, while remote or embedded providers can provide a URL or opaque provider-managed guidance. A name that cannot be resolved reports that the skill is unknown or no longer available; invalid names and skills marked `disableModelInvocation: true` retain distinct `isError` results. +Execution uses the calling agent's `session.header.cwd` so workspace-sensitive providers resolve the winning skill. A successful call returns one text result containing ``, ``, and ``. + +Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`; scripts, references, and assets load on demand, and the result does not enumerate a skill directory. Local providers may supply a directory, while remote or embedded providers may supply a URL or opaque loading guidance. + +An unresolved name reports that the skill is unknown or no longer available. Invalid names and `disableModelInvocation: true` skills produce distinct error results. The tool does not call `agent.inject()` in v1. Its result is already recorded as the tool result and becomes available to the next model step without duplicating the content as synthetic context. diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 8539c1d517..50c4b0db74 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -66,8 +66,8 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('dsh-tool-skill: registered skill tool is not visible in the global registry') } - // Register after the tool so reverse-order fiber teardown removes this guidance listener - // before its referenced tool. + // Register after the tool so reverse teardown removes guidance first. Exact definition + // identity prevents a scoped shadow merely named `skill` from inheriting this catalog. ctx.on('agent/session-prefix', async (agent, _prefix, signal, next): Promise => { if (ctx.tools.get(skillTool.name, agent) !== registeredSkillTool) return await next() const skills = await ctx.skills.list({ cwd: agent.session.header.cwd, signal }) diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 9b6d4eca5f..80766ed831 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -1,7 +1,8 @@ /** - * The out-of-process ACP subagent backend: registers a {@link SubagentProvider} on - * `ctx.subagents` that runs each child agent in a spawned SUBPROCESS, driven over the Agent - * Client Protocol (ACP) as the client. + * Out-of-process ACP subagent backend. Each child has its own process, session, model, and + * tools, so it shares no Cordis context, ignores `request.parent`, and advertises no parent- + * enforced start capabilities. This plugin uses named exports only; a default would hide its + * loader metadata (see `docs/postmortem/0001-acp-default-export-drops-inject.md`). * @module @deepseek-ai/dsh-subagent-acp */ diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 978d1cb596..9416d58865 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -1,6 +1,10 @@ /** - * Fresh-process ACP subagent client. Drives one child session and owns process - * cancellation and quiescent disposal. + * Fresh-process ACP subagent client. Drives one child session and owns cancellation and + * quiescent disposal. + * + * TODO(acp-subagent-replay): add snapshot-tier coverage with a separate replay fixture and + * sessions root inside each child process. Current keyless coverage uses a scripted ACP child; + * with-key coverage drives the real ACP example. * @module @deepseek-ai/dsh-subagent-acp/run */ @@ -24,7 +28,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' -/** Fixed response to child permission requests: reject, or first allow option. */ +/** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' /** Resolved spawn spec for an ACP child process (no defaults — see Config). */ @@ -68,7 +72,7 @@ export interface AcpRunSpec { onError?: (error: Error, stopReason: SubagentStopReason) => void } -/** Default EOF grace for child flush and nested-process teardown before signaling. */ +/** EOF grace for child flush and nested-process teardown; wider than the signal grace below. */ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 09a72eadb3..000f6d49f0 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -1,7 +1,9 @@ /** - * A minimal mock ACP AGENT, run as a subprocess, for the keyless `dsh-subagent-acp` tests. It - * speaks the agent side of ACP over stdio and is fully scripted by environment variables — no - * model, no network. + * Minimal no-network ACP child process for keyless backend tests. Environment variables script its + * text and stop reason, a cancel-cooperative or cancel-ignoring hang, permission requests, and a + * readiness marker. Disposal fixtures can delay an EOF flush, ignore EOF but exit and mark + * SIGTERM, or trap SIGTERM to require SIGKILL. The specs spawn this non-test module under tsx with + * an explicit tsconfig, mirroring real example boot. * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server */ @@ -122,8 +124,8 @@ function makeAgent(conn: AgentSideConnection): Agent { process.exit(1) } if (IGNORE_CANCEL) { - // A NON-COOPERATIVE child: receive session/cancel but never resolve the pending prompt - // and never exit. + // A non-cooperative child receives cancellation but neither resolves nor exits. The + // backend must still settle `aborted`, and disposal must kill the process. return Promise.resolve() } resolveCancel?.('cancelled') @@ -142,7 +144,7 @@ new AgentSideConnection( // Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process neither quiesces // on EOF nor dies on the graceful signal — exercising the backend dispose path's SIGKILL -// escalation. +// escalation. READY_FILE proves the trap was armed before the test disposes the run. if (process.env.MOCK_TRAP_SIGTERM === '1') { process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) // Keep the event loop alive (a bare timer) so nothing else lets it exit. @@ -152,7 +154,8 @@ if (process.env.MOCK_TRAP_SIGTERM === '1') { // Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on stdin 'end' (the // dispose path's `child.stdin.end()`), take an ASYNC beat to "flush", then touch the marker and -// exit ON OUR own — no signal involved. +// exit on its own. A signal sent before MOCK_FLUSH_DELAY_MS would suppress the marker, so it proves +// the EOF grace window was long enough for durable flush. if (FLUSH_ON_EOF !== undefined) { const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') process.stdin.on('end', () => { @@ -163,7 +166,9 @@ if (FLUSH_ON_EOF !== undefined) { }) } -// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier. +// Ignore EOF but exit on SIGTERM to exercise the middle disposal tier before SIGKILL. The signal +// marker distinguishes that catchable rung from an immediate, uncatchable SIGKILL; READY_FILE +// proves the handler was armed before disposal. if (process.env.MOCK_IGNORE_EOF === '1') { const sigtermFile = process.env.MOCK_SIGTERM_FILE process.on('SIGTERM', () => { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index f52c75e72b..bd4085a1a8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -9,7 +9,9 @@ import SubagentService from '@deepseek-ai/dsh-subagent' import * as acp from '../src/index.ts' /** - * With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP server. + * With-key cross-process seam proof: the backend spawns the real acp-agent example, speaks ACP over + * stdio, and returns its real model answer. This is the out-of-process counterpart to in-process + * spawn coverage and self-skips without `DEEPSEEK_API_KEY`. */ // The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 1656fb81e5..fa212bc938 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -2,7 +2,8 @@ * The in-process FORK subagent backend: registers a {@link SubagentProvider} on * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a prefix of the * parent's session log — so the child inherits the parent's conversation context instead of - * starting fresh. + * starting fresh. The seed ends at the last `turn/end`: the current tool-call turn is + * unbalanced and cannot be replayed as a valid child session. * @module @deepseek-ai/dsh-subagent-fork */ @@ -32,8 +33,9 @@ export const Config: z = z.object({ /** * The balanced completed-turn prefix of `parent`'s log: every event up to and including the - * last `turn/end`. - * + * last `turn/end`. The in-flight turn is excluded; before any completed turn the child starts + * fresh. Because live sequence numbers equal array indexes, the result remains a valid seed + * beginning at sequence zero. * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 588604b2c1..b5e129e54f 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -136,7 +136,8 @@ describe('dsh-subagent-fork', () => { it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => { // Drive the parent so it has one completed turn, then start a SECOND turn that is still - // open (a hanging model call), and fork while it's in flight. + // open (a hanging model call), and fork while it's in flight. The seed must stop after the + // balanced first turn; including the open turn would fail invariant replay during start. const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) parent.send([{ type: 'text', text: 'q1' }]) await parent.whenIdle() @@ -181,7 +182,8 @@ describe('dsh-subagent-fork', () => { }) it('does NOT return the seeded parent output when the child produces no message of its own', async () => { - // Regression: readResult must scope to the child's own events (after the seed). + // `readResult` must scan only child-owned events after the seed. The child emits no assistant + // message, so scanning the whole log would incorrectly return the parent's distinctive text. const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) parent.send([{ type: 'text', text: 'parent question' }]) await parent.whenIdle() diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 835c3aa0c0..09aa2d24b7 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -1,6 +1,12 @@ /** - * Child-scoped structured-output tool, prompt instruction, terminal guard, and - * authoritative result capture for in-process subagents. + * Child-scoped structured-output tool, prompt instruction, terminal guard, and authoritative + * result capture for in-process subagents. Each child registers its real schema on its own + * scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt + * contribution is ordinary reconstructed request state. + * + * Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also + * waits for the enclosing `run_code` result. The terminal turn-stop and monotonic tool guard + * then prevent later listeners or calls from reopening a completed structured run. * @module @deepseek-ai/dsh-subagent-inprocess/structured */ diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 5a751ac99d..a2c55995c2 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -35,7 +35,11 @@ const SCHEMA: StructuredOutputSchema = { required: ['answer'], } -/** Real loop and inline provider without a backend package dependency cycle. */ +/** + * Real loop, scripted model, and inline fresh-conversation provider over the shared driver. Loading + * spawn/fork here would create a dev-dependency cycle; their specs cover plugin integration while + * this fixture isolates driver behavior and scripts the child's `structured_output` calls. + */ async function setup(script: Script, options: SetupOptions = {}) { const ctx = new Context() const adapter = new MockAdapter(script) @@ -209,7 +213,9 @@ describe('in-process structured output', () => { ]) ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) let wrapperInstalled = false - // Install a wrapper before the child loop can run. + // Register before ready-only start: structured output is attached before session-start and the + // loop. The wrapper waits for a downstream stop, rewrites it to continue, and must still lose + // to the later terminal checkpoint. ctx.on('agent/session-start', (child) => { if (child === parent) return wrapperInstalled = true @@ -233,7 +239,8 @@ describe('in-process structured output', () => { toolCallResponse('c1', STRUCTURED_OUTPUT_TOOL, { answer: 9 }), textResponse('MUST NOT BE CONSUMED'), ]) - // The terminal checkpoint must discard steering queued by a wrapper. + // A downstream policy stops, then a later wrapper delegates and queues steering that ordinary + // folding would turn into continue. The terminal checkpoint must discard that steering. ctx.on('agent/turn-continuation', () => Promise.resolve({ action: 'stop' })) const run = await ctx.subagents.start('spawn', structuredRequest(parent)) ctx.on('agent/session-start', (child) => { diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index 7bd4b68377..c006272cd5 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -12,8 +12,8 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' export const name = 'subagent-spawn' -// `tools` is deliberately not injected: the shared driver registers structured output through -// the child's creation context, whose factory already requires the tool service. +// `tools` is deliberately not injected: the child factory already provides it during setup, +// and adding it here would unnecessarily change this provider's apply timing. export const inject = ['subagents'] /** Config: the registry name to register the provider under. */ diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 08cda6e89f..43ec8ee1ac 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -152,7 +152,8 @@ describe('dsh-subagent-spawn', () => { }) it('rejects without publishing when the request signal is already aborted', async () => { - // An already-aborted signal will not emit another abort event. + // An already-aborted signal emits no future event, so start must check it before listening and + // settle aborted without running the child. The empty model script proves no turn occurs. const controller = new AbortController() controller.abort() const { ctx, parent } = await setup([]) @@ -161,7 +162,8 @@ describe('dsh-subagent-spawn', () => { }) it('same-tick cancellation rejects start and prevents child publication', async () => { - // Same-tick cancellation must win before publication. + // Same-tick cancellation must win before async factory publication: no child may become + // visible, `started` must not fulfill, and the empty script proves no model turn occurs. const { ctx, parent } = await setup([]) const beforeAgents = ctx.agents.list().length const beforeSessions = ctx.sessions.list().length diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index ddc5b3f769..21bcca788e 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -1,7 +1,8 @@ /** * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external * agent as a child process and must keep the parent deployment's credentials out of it, tear - * it down to quiescence, and isolate it from the host user's on-disk CLI state. + * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package + * registers no provider; consuming plugins own and validate every timing or path default. * @module @deepseek-ai/dsh-subagent-subprocess */ @@ -39,8 +40,8 @@ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv } /** - * Capture the child's spawn-level failure as a promise the run's result path can race. - * + * Capture the child's spawn-level `error` event as a promise. Call in the same tick as + * `spawn()`; otherwise an early event can be unhandled and crash the parent. * @param child - the just-spawned child process. * @returns a promise that RESOLVES (never rejects) with the child's first * `error` event; for a child that spawns cleanly it never settles. @@ -109,8 +110,8 @@ export interface DisposeLadderGraces { } /** - * Tear a child process down to QUIESCENCE: resolves only once the child has actually exited - * (or was already gone), never merely after requesting it. Three-tier escalation — + * Tear a child process down to quiescence, resolving only after exit: close stdin and allow + * cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit. * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. @@ -118,7 +119,7 @@ export interface DisposeLadderGraces { export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { // Already gone: nothing to reap. if (child.exitCode !== null || child.signalCode !== null) return - // 1. + // 1. Close stdin and allow cooperative teardown and durable-state flush. child.stdin?.end() if (await exitsWithin(child, graces.disposeEofGraceMs)) return // 2. SIGTERM, escalating if the child still does not exit within the grace. @@ -147,9 +148,9 @@ export interface IsolatedConfigDir { } /** - * An isolated config dir for one child run, so the child's behavior is a function of - * deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state happens to - * exist on the host machine. Two modes. + * An isolated config dir for one child run, independent of host CLI state. Without + * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory + * is returned unchanged and remains deployment-owned. * * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 78b7efd1c8..bdc0260c73 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -15,8 +15,8 @@ import { waitForExit, } from '../src/index.ts' -// `rm` is wrapped (real-passthrough by default) so one test can inject a rejection -// deterministically. +// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm +// failures are not portable and disappear under root, so this is the sanctioned filesystem seam. vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, rm: vi.fn(actual.rm) } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index f83df7a4fe..05bb40d575 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -14,7 +14,9 @@ import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-t * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent - * degradation" rule). + * degradation" rule). These static flags cover features needed before a run exists; runtime + * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence + * is the capability. */ export interface SubagentCapabilities { /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ @@ -54,8 +56,9 @@ export interface SubagentStartRequest { /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions /** - * Supported object-rooted JSON Schema for {@link SubagentResult.structured}. - * Requires the provider capability and plain host-realm JSON data. + * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; + * a successful child returns the matching value as {@link SubagentResult.structured}. */ readonly outputSchema?: StructuredOutputSchema /** @@ -124,8 +127,9 @@ export interface SubagentResult { } /** - * Ready child handle. Consumers await {@link result} and always {@link dispose} - * for quiescence. Optional methods indicate their runtime capabilities. + * Child handle returned only after readiness. Consumers await {@link result} and must always + * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime + * capability discovery; narrow their presence before calling. */ export interface SubagentRun { /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ @@ -170,9 +174,9 @@ export interface SubagentProvider { /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities /** - * Whether a child receives the parent's completed conversation history. This - * descriptive fact drives tool wording; it says nothing about services, tools, - * or authority. + * Whether the child sees the parent's completed-turn prefix. This is descriptive, not a + * service-validated start capability: the model-facing tool derives truthful wording from it. + * It says nothing about tool registration, injected services, or authority inheritance. */ readonly inheritsParentContext: boolean /** diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 34f89d072f..ef75d1fe97 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,6 +1,11 @@ /** - * Provider-bound model tool that delegates to one child agent, awaits its - * result, and always disposes the run. Provider lifecycle controls registration. + * Model-facing delegation tool bound by configuration to one provider; transport selection is not + * exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and + * re-derives conversation-history wording after reload, so load order is irrelevant. + * + * Execution synchronously awaits the child result and always disposes the run. Non-completed stop + * reasons become error results, while transport details remain behind `ctx.subagents`. Load this + * plugin more than once to expose multiple configured providers. * @module @deepseek-ai/dsh-tool-subagent */ @@ -77,7 +82,8 @@ export const Config: z = z.object({ model: z.string(), }).default(undefined as unknown as { model: string }), persona: z.string(), - // Preserve omitted filters and nested lists; an empty allow-list means deny all. + // Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which + // silently means deny all. Preserve omission while retaining an explicit empty allow-list. toolFilter: z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), @@ -253,7 +259,7 @@ export function apply(ctx: Context, config: Config): void { if (present !== undefined) { mount(present) } else { - // Not an error: the backend's fiber may simply activate after this one. + // Not an error: the backend's fiber may activate after this one. // The tool appears the moment the provider registers; a typo'd provider // name shows up as this note plus a tool that never materializes. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index a47cfaa925..f025b53196 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -39,4 +39,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, and prompt snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script can queue permission answers by stable option kind and can set session config options or assert their rejection. Missing permission answers cancel; selecting an unavailable kind fails the scenario. +`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script can queue permission answers by stable option kind and can set session config options or assert their rejection in the transcript. Missing permission answers cancel; selecting an unavailable kind fails the scenario. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index e5f7571fa3..c9d28a1bd0 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -1,7 +1,8 @@ /** - * Shared subprocess harness for ACP snapshot suites. A library module driven by the suite - * factory in ./suite.ts (and directly by harness-level specs); each example's `*.snapshot.ts` - * names its own agent-under-test paths. + * Shared ACP snapshot subprocess harness. It boots the real agent bin through the Cordis + * loader, drives deterministic ACP JSON-RPC over stdio, captures protocol-pure stdout, and + * harvests persisted session logs after graceful shutdown. Normalization stays in + * `normalize.ts`; suite registration stays in `suite.ts`. * @module @deepseek-ai/dsh-acp-snapshot/harness */ @@ -57,8 +58,8 @@ export interface AgentUnderTest { /** * One step of a scenario's deterministic input script (`input.json`). The harness interprets * these in order. `newSession` captures the server-issued (random) session id into a - * `{{sessionId}}` variable that later steps reference, since a committed file cannot know the - * id in advance. + * `{{sessionId}}` variable that later steps reference. `promptAndCancel` sends without awaiting, + * waits for the first streamed message, then cancels, making transcript order deterministic. */ export type InputStep = | { op: 'initialize'; terminalOutput?: boolean } @@ -75,8 +76,9 @@ export type InputStep = export interface InputScript { steps: InputStep[] /** - * Ordered answers for the agent's `session/request_permission` round-trips, consumed FIFO — - * the Nth request gets the Nth answer. + * FIFO permission answers selected by stable option kind; the harness maps each kind to the + * agent-issued option id. Exhaustion cancels, while a kind the agent did not offer fails the + * scenario. */ permissionAnswers?: PermissionAnswer[] } @@ -202,8 +204,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise child.stderr.setEncoding('utf8') child.stderr.on('data', (c: string) => stderrChunks.push(c)) - // Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO feed the - // same bytes to the SDK client through a passthrough. + // Tee the same raw bytes to the golden and SDK client. Decode once at the end so a UTF-8 + // sequence split across stream chunks cannot corrupt the transcript. const passthrough = new Readable({ read() {} }) child.stdout.on('data', (buf: Buffer) => { rawBuffers.push(buf) @@ -226,8 +228,8 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] - // A scenario bug detected inside a client callback (a scripted permission kind the agent - // never offered). + // A callback throw would become only an RPC error the agent could absorb. Record an + // impossible permission choice here, answer cancelled, and fail the outer scenario. let scriptError: Error | undefined const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { @@ -353,7 +355,8 @@ async function runStep( case 'promptAndCancel': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession') - // Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on its own). + // A hang fixture never resolves alone. Wait for its streamed chunk before cancellation + // so updates deterministically precede the cancelled prompt response. const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk') await client.cancel({ sessionId }) @@ -439,7 +442,8 @@ async function harvestSessionLogs(root: string): Promise { }) } } - // Primary (no parentSession) first, then children by ascending createdAt. + // Match replay fixture assignment: primary first, then children by creation time, with id as + // a deterministic collision tiebreaker. logs.sort((a, b) => { const ap = a.parentSession === undefined ? 0 : 1 const bp = b.parentSession === undefined ? 0 : 1 diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 43fe75b9cb..5afb7ecf66 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,6 +1,7 @@ /** - * ACP snapshot suite kit — the shared machinery behind the keyless snapshot tier (`pnpm run - * test:snapshot`). + * ACP snapshot suite kit: subprocess scenario harness, pure golden normalizers, and the Vitest + * suite factory behind `pnpm run test:snapshot`. Because this entry exports `suite.ts`, importing + * it requires a Vitest run. * @module @deepseek-ai/dsh-acp-snapshot */ diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 15e542e0f0..81ee42e9e3 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -1,8 +1,8 @@ /** - * Pure normalizers for the ACP snapshot goldens. They replace the non-deterministic values in - * the two captured surfaces — the stdout JSON-RPC transcript and the persisted session JSONL — - * with stable tokens, so a golden compare reflects behavior, not run-to-run noise. Kept - * dependency-free and side-effect-free so they unit-test trivially. + * Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids, + * timestamps, and hook duration while preserving deterministic event sequence numbers. + * Request-header scrubbers stay separate so one scenario per header class can pin tools and a + * readable prompt while other fixtures omit duplicated header bulk. * @module @deepseek-ai/dsh-acp-snapshot/normalize */ @@ -50,6 +50,7 @@ function scrubValue(value: unknown, ctx: NormalizeContext): unknown { * Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a stable golden * in the same shape as the wire: one compact JSON frame per line (NDJSON), with the JSON-RPC * `id` rewritten to a per-transcript sequence (1, 2, 3, …) and all volatile strings scrubbed. + * Invalid JSON throws, doubling as a protocol-stdout purity check. * * @param rawStdout The captured stdout bytes, decoded utf8. * @param ctx The run's volatile values to scrub. diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index e7936dd407..8930b03d2f 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -1,5 +1,12 @@ /** - * The ACP snapshot suite factory (replay by default, keyless). + * Keyless-by-default ACP snapshot suite factory. Each scenario drives the real subprocess and + * compares normalized stdout; comparable session fixtures are both replay input and expected + * output. Record mode refreshes reproducible model scenarios from the live API, while refresh + * mode replays committed scripts and rewrites derived artifacts without a key. + * + * Exactly one scenario per header-composition class pins tool schemas in JSONL and the system + * prompt in Markdown. Every live header is checked against that pin, so session-dependent + * composition must declare a separate class instead of escaping coverage. * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -61,7 +68,8 @@ export interface Scenario { */ childSessions?: number /** - * Whether this scenario pins its header class's model-facing request-header content. + * Whether this scenario is its header class's sole request-header pin. Its Markdown file owns + * the prompt, its JSONL keeps tool schemas, and every classmate is checked for equality. */ pinsHeader?: boolean /** @@ -123,8 +131,9 @@ export function childFixturePaths(dir: string, childSessions: number): string[] } /** - * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own header line - * (`{ type: 'session', id, cwd }`). + * Derive normalization values from a fixture's own session header. Recorded ids and cwd differ + * from the live replay run; the non-empty sentinel for missing cwd avoids accidental empty- + * string replacement. * * @param fixture The committed `session.jsonl` content. * @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}. @@ -403,8 +412,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { cwd: result.cwd, } - // RECORD mode (recorded model scenarios only): persist the freshly-harvested live logs - // back to their fixtures. + // Record writes live model fixtures; keyless refresh writes every comparable replayed + // fixture. Pins keep tools but all JSONL files scrub prompt text. const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 553843df3d..d5fcd75a93 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -1,5 +1,7 @@ /** - * Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. + * Scripted ACP agent for snapshot-kit tests. A fixture-adjacent `behavior.json` controls the + * subprocess reached through the real harness path; the bin reports observations over ACP and + * writes scripted logs before exiting on stdin EOF. */ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b5da987a3c..01b93dc81e 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -308,7 +308,8 @@ describe('runScenario', () => { it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true }) - // The fake bin offers allow_once/reject_once; scripting allow_always is a scenario bug. + // The fake offers only allow_once/reject_once. The harness must reject an impossible click, + // not merely send an RPC error that a tolerant agent could absorb. await expect(runScenario( { steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] }, { agent: AGENT, mode: 'replay', fixtureFile }, diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 0d8b25a5ba..c76dd32a92 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -23,6 +23,9 @@ import { * so every factory path — golden and log compares, the per-suite header pin and its uniformity * guard, record-mode fixture write-back, skip semantics, and the fixture guard block — * executes as an ordinary green test. + * + * Record tests use a temp copy. To intentionally rebuild their committed fixtures, run this + * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree. */ const AGENT = { diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index a73a886457..fe8ff87d7a 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -39,7 +39,7 @@ Agent status (per agent): Model requests (on `llm/stream`): -- **a loop-built request is exactly what the log reconstructs** — frozen requests with a live `sessionId` must match a fresh derivation bounded before the in-flight `step/start`, while non-content fields match the folded request headers. The check is prepended so ordinary short-circuiting stream listeners cannot skip it; correctness comes from the sequence boundary, not listener order. See the [reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). +- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` is rebuilt through a fresh `Session` from the prefix before its in-flight `step/start`; later content belongs to the next request, and hand-built unfrozen one-shots are excluded. Frozen messages must match that derivation, while every other field matches folded `request/header*` events. The prepended check runs before ordinary short-circuiting stream listeners, but correctness comes from the sequence boundary rather than listener timing. See the [reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md). On any violation it throws `InvariantError` (`code: 'INVARIANT'`). diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 9f56e75ec5..547319c8b9 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -1,6 +1,8 @@ /** - * Dev-only listener plugin for cross-event lifecycle, scope, and request - * invariants that types cannot express. + * Dev-only listeners for relationships that event types and immutability cannot express: turn and + * step nesting, scoped dispatch, status transitions, and request reconstruction. Enable in tests + * and demos, not production. Sessions already snapshot and freeze individual events; this plugin + * checks the cross-event contract and serves as its executable documentation. * @module @deepseek-ai/dsh-invariants */ @@ -315,19 +317,15 @@ function replayEvent(trace: SessionTrace, event: SessionEvent): void { applyTransition(trace, validateEvent(trace, event)) } -/** Legal agent status transitions (the only state machine the loop guarantees). */ +/** Allow an initial observation, idle/running transitions, and terminal disposal; reject repeats and leaving disposed. */ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void { - // First observation: any status is a valid starting point. if (from === undefined) return - // A no-op transition is illegal — setStatus dedups, so we never see it. if (from === to) { throw new InvariantError(`agent/status repeated ${to} (no-op transition)`) } - // Leaving `disposed` is illegal — disposal is terminal. if (from === 'disposed') { throw new InvariantError(`agent/status left terminal state disposed → ${to}`) } - // idle↔running and (idle|running)→disposed are all legal; nothing else exists. } /** diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index bcaf93bd5f..24046fa8dd 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -325,19 +325,17 @@ describe('HMR state rebuild', () => { it('rebuilds trace state for a session that exists at (re-)apply time', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - // First registration, mid-turn: a turn is open when the plugin reloads. const first = await ctx.plugin(Invariants) const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) await first.dispose() - // Re-apply (HMR): the fresh fiber must replay the existing log so the open - // step is known — the next chunk must NOT be a false positive. + // Re-apply mid-step: the new fiber must reconstruct the open boundaries from the log. await ctx.plugin(Invariants) expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })) .not.toThrow() - // And a genuine violation is still caught after the rebuild. + // Rebuild must not disable later violations. expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) .toThrow(/turn 1 is still open/) }) @@ -541,25 +539,17 @@ describe('surface invariants', () => { }) it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { - // The unknown-seq check fires when a ref passes the "earlier" test but is - // not in knownSeqs — only possible with a gap in seqs. We create a gap by - // directly manipulating the private log array to skip a seq. + // Create an impossible-through-public-API gap so seq 2 is earlier but unknown. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) - // Push a fake event at seq 3 into the internal log, creating a gap at seq 2. - // The invariants plugin replays session.events on every append, so it sees - // this gap during trace reconstruction. ;(session as unknown as { log: unknown[] }).log.push({ type: 'assistant/chunk', seq: 3, time: Date.now(), data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }, }) - // Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes - // is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not - // in knownSeqs ({0, 1, 3} — gap at 2). expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) }).toThrow(/unknown seq 2/) @@ -803,7 +793,8 @@ describe('request-reconstruction cross-check (llm/stream)', () => { describe('request cross-check ordering (prepend)', () => { it('runs ahead of a short-circuiting llm/stream listener registered before it', async () => { - // The prepended check must run before a short-circuiting replay listener. + // Replay short-circuits without next(), so the check prepends ahead of ordinary listeners; + // correctness still comes from its sequence-bounded rebuild, not listener timing. const ctx = new Context() await ctx.plugin(SessionStore) ctx.on('llm/stream', () => (async function* () {})() as never) // short-circuits, no next() diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 22dfdd624f..2e509973e5 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -1,5 +1,8 @@ /** - * Replay LLM plugin for snapshot tests. + * Keyless snapshot-test LLM replay. It derives one model-call script per + * recorded session from `assistant/chunk` events and binds fresh live sessions + * to parent/child scripts by first-call order. Throw and hang cases require an + * explicit override because a session log cannot reconstruct them alone. * @module @deepseek-ai/dsh-llm-replay */ @@ -11,11 +14,9 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' /** - * One recorded model call. A discriminated union (not a bare `StreamChunk[]`) so it can - * faithfully replay BOTH branches of the documented LLM failure contract — an adapter may - * THROW from `stream()` or end with a `finish` error chunk — plus a `hang` marker for - * cancellation scenarios (mirrors the `MockAdapter` `hang` support in - * packages/core/agent-loop/tests). + * One recorded model call. `throw` may replay prefix chunks before failing; + * `hang` models cancellation. Only ordinary chunk entries derive from JSONL; + * the other variants come from an override sidecar. */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } @@ -46,7 +47,10 @@ export interface ReplayConfig { childFiles?: string[] } -/** Recorded calls plus header facts used to order parent and child replay scripts. */ +/** + * Recorded calls plus header facts used to order parent and child scripts. + * Recorded ids are diagnostic; fresh live ids bind by ordered first use. + */ export interface SessionScript { /** The recorded session id (diagnostics only — the live id differs). */ recordedId: string @@ -71,9 +75,7 @@ export interface SessionScript { export function parseSessionLog(text: string): SessionEvent[] { const lines = text.split('\n').filter(line => line.trim().length > 0) const events: SessionEvent[] = [] - // Skip line 0 (the header). A reader distinguishes it by its `type:'session'` - // tag; we simply drop the first line, which the JSONL backend guarantees is - // the header. + // The JSONL backend guarantees line 0 is the session header. for (let i = 1; i < lines.length; i++) { const parsed: unknown = JSON.parse(lines[i] as string) events.push(parsed as SessionEvent) @@ -100,6 +102,9 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe /** * Reconstruct the per-`stream()` replay script from a recorded session log. * + * Groups `assistant/chunk` events by turn and step. Every group must end in a + * `finish`; a missing terminator means the live stream threw, so derivation + * rejects and the scenario must provide an explicit override. * @param events - the recorded session's events; only `assistant/chunk` is consulted. * @returns one `chunks` entry per recorded model call, in call order. */ @@ -158,8 +163,8 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { } /** - * Load every recorded session's script for a scenario, ordered by `createdAt` (earliest - * first), ready to bind to live sessions in first-call order. + * Load the primary and child scripts in bind order. Child derivation begins at + * `seedLength` so inherited parent chunks are never replayed as child calls. * * @param config - the fixture paths: the primary log plus any recorded child logs. * @returns the primary script first, then the child scripts in bind order. @@ -236,9 +241,10 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) } /** - * Install the replay `llm/stream` listener on `ctx`. Returns the listener disposer (so a fiber - * dispose removes it — HMR safety). Exported separately from {@link apply} so unit tests can - * drive it without the Loader or env vars. + * Install per-session positional replay. A newly seen live session takes the + * next ordered recorded script, then advances its own cursor synchronously at + * invocation time; calls without `sessionId` share one anonymous session. + * Returns the effect disposer for HMR-safe removal. * * @param ctx - the context whose `llm/stream` waterfall the listener short-circuits. * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job). diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 8535ab604b..144232439d 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -427,8 +427,8 @@ describe('loadSessionScripts', () => { }) it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => { - // A fork child's log begins with the seeded parent prefix — the parent's events, INCLUDING - // its assistant/chunk events. + // A fork log includes the parent's assistant chunks before `seedLength`. Deriving from the + // whole log would replay parent responses as child calls, so only child-owned chunks qualify. const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' } const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }] const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) @@ -485,7 +485,8 @@ describe('loadSessionScripts', () => { }) it('keeps the primary first even when a child sorts BEFORE it in input order', () => { - // Equal creation times keep the primary first regardless of input order. + // The primary is appended first internally. A strictly earlier child sorts before it, while + // equal creation times preserve primary-first order regardless of input order. const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS]) const scripts = loadSessionScripts({ file: f, childFiles: [earlier] }) diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index 54417aa9af..5032c92529 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -1,6 +1,7 @@ /** - * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a model or a real - * child agent. + * Scripted, model-free subagent provider for deterministic coverage of registration, + * capability checks, lifecycle, the model-facing tool, and structured results through the real + * loader path. It is a named-export functional plugin; no default export. * @module @deepseek-ai/dsh-subagent-mock */ diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 34c1e72e67..c9700a14b1 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -105,7 +105,7 @@ describe('dsh-subagent-mock', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // Loader must retain this namespace's injection metadata. + // A default export would make Loader unwrap only that value and drop `inject`. expect('default' in mock).toBe(false) expect(mock.name).toBe('subagent-mock') expect(mock.inject).toEqual(['subagents']) diff --git a/packages/timeout/timeout-policy/src/index.ts b/packages/timeout/timeout-policy/src/index.ts index 30d67dd434..70ead41e1b 100644 --- a/packages/timeout/timeout-policy/src/index.ts +++ b/packages/timeout/timeout-policy/src/index.ts @@ -1,5 +1,7 @@ /** - * `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout policy. + * Cooperative tool-call timeout enforcer. A tool declares `timeoutMs` and + * promises to honor `exec.signal`; this wrapper arms that deadline and maps its + * own expiry to `TOOL_TIMEOUT` without racing or abandoning the tool promise. * @module @deepseek-ai/dsh-timeout-policy */ @@ -42,7 +44,9 @@ export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecut } /** - * Register the tool-call timeout enforcer. + * Register the timeout wrapper. It resolves the caller-visible tool definition, + * temporarily replaces `exec.signal`, delegates, restores the upstream signal, + * and replaces the result only when this wrapper's own timer fired. */ export function apply(ctx: Context): void { ctx.on('tools/execute', async (exec, next): Promise => { diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 19def206c5..039d2085f7 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -1,6 +1,7 @@ /** - * The model-facing `todo_write` tool: the agent's whole task list, replaced wholesale on each - * call. + * Model-facing whole-list replacement. Each call appends a `todo/write` snapshot to the calling + * agent's session; replay is last-write-wins, and UIs render from session events. A non-agent + * caller has no owning list and is rejected. Named exports preserve loader injection metadata. * @module @deepseek-ai/dsh-tool-todo */ @@ -28,7 +29,8 @@ const DESCRIPTION = /** * Validate the value constraints the SchemaSpec can't express and build the canonical {@link - * TodoItem}[]. + * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry + * has already enforced the status enum; the cast below records that guarantee. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index bdb14a5518..c2843cbcfe 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -149,7 +149,7 @@ describe('dsh-tool-todo', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => { - // Loader must retain this namespace's injection metadata. + // A default export would make Loader unwrap only apply and drop `inject`. expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-todo') expect(tool.inject).toEqual(['tools']) diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index febb3e50ca..f662654d42 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -1,8 +1,10 @@ #!/usr/bin/env node /** - * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that loads the {@link - * @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter and a bash executor), speaking - * ACP JSON-RPC on stdio. + * Boot an ACP stdio server from `cordis.yml`; usage is `dsh-acp-agent [config]`, defaulting to the + * cwd file. Shared env loading, Loader guards, snapshot config selection, and settled-tree boot live + * in dsh-app-boot. Replay skips `.env` and selects sibling `cordis.snapshot.yml` so a stray key + * cannot trigger a model call. EOF disposes and flushes snapshot runs; editors normally own process + * lifetime. Stdout is reserved for JSON-RPC—write diagnostics only to stderr. * @module @deepseek-ai/dsh-acp-agent/bin */ diff --git a/packages/ui/acp-agent/src/index.ts b/packages/ui/acp-agent/src/index.ts index 7c6ae6ba69..2da03aef20 100644 --- a/packages/ui/acp-agent/src/index.ts +++ b/packages/ui/acp-agent/src/index.ts @@ -2,6 +2,10 @@ * The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-core}) plus the * coupled front-door cluster an ACP server needs — JSONL session persistence and the {@link * @deepseek-ai/dsh-acp} bridge, and deliberately NOTHING that writes to stdout. + * It pre-creates no agents and leaves adapters, executors, and optional tools to + * the leaf, which must likewise avoid stdout loggers. Named exports are + * required so Loader retains this plugin's `Config` schema (see + * docs/postmortem/0001). * @module @deepseek-ai/dsh-acp-agent */ diff --git a/packages/ui/acp-agent/tests/acp-agent.spec.ts b/packages/ui/acp-agent/tests/acp-agent.spec.ts index 7b3ce00a58..52f62d8ac8 100644 --- a/packages/ui/acp-agent/tests/acp-agent.spec.ts +++ b/packages/ui/acp-agent/tests/acp-agent.spec.ts @@ -139,7 +139,8 @@ describe('dsh-acp-agent composition', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { - // Loader must retain the namespace so name, Config, and apply survive unwrapping. + // A default export would make `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. expect('default' in acpAgent).toBe(false) expect(typeof acpAgent.apply).toBe('function') diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index 16945708ea..7aec1ecab0 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -18,9 +18,10 @@ import { Readable, Writable } from 'node:stream' import { afterEach, describe, expect, it } from 'vitest' /** - * Built-ARTIFACT smoke for the published `dsh-acp-agent` bin. `load-path.e2e.ts` boots - * `src/bin.ts` under tsx — but the package's `bin` field points at `lib/bin.js`, run under - * plain `node` by a real consumer. + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and + * require a valid initialize response. This catches built-only settle races and stdout protocol + * leaks that the tsx source-path smoke cannot. It skips before build; initialize is keyless, with a + * dummy key used only to boot the adapter. `--expose-internals` enables Cordis bare-plugin loading. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) @@ -37,7 +38,8 @@ const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', 'schemastery', 'cosmokit', ] -// Third-party deps the ACP bridge needs at runtime. +// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict +// layout need not hoist them. Symlink those exact paths into the plain-Node consumer. const npmDeps = ['@agentclientprotocol/sdk', 'zod'] const acpPkgDir = join(repoRoot, 'packages/ui/acp') @@ -145,7 +147,8 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A typo'd config path must fail clearly, not exit 0. + // A nonexistent directory prevents even the include plugin import. Loader logs the failure and + // leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml') expect(code).not.toBe(0) expect(stderr).toContain('failed to load') diff --git a/packages/ui/acp-agent/tests/load-path.e2e.ts b/packages/ui/acp-agent/tests/load-path.e2e.ts index 85545abd67..6507097bc0 100644 --- a/packages/ui/acp-agent/tests/load-path.e2e.ts +++ b/packages/ui/acp-agent/tests/load-path.e2e.ts @@ -17,9 +17,11 @@ import { } from '@agentclientprotocol/sdk' /** - * real-load-path smoke for @deepseek-ai/dsh-acp-agent: boot the app through its own `bin` (the - * demo:acp entry) as a subprocess, driving the cordis Loader and `unwrapExports` over a - * minimal `cordis.yml` that loads THIS package. + * Source-path Loader smoke through the package's own bin, covering initialize, session/new, and + * session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and + * unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd + * is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable + * when the child starts outside the repository. */ const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) @@ -119,7 +121,9 @@ describe('dsh-acp-agent real-load-path smoke (bin + Loader, keyless)', () => { expect(sessionId).toBeTruthy() // session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN - // id (loading the live `sessionId` would correctly reject as "already loaded"). + // id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence + // and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches + // not-found, while a collapsed export would fail earlier with missing injection. const unknownId = '00000000-0000-4000-8000-000000000000' await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then( () => { throw new Error('expected session/load of an unknown id to reject') }, diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index a26ded7b3b..d03fdcb277 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -1,5 +1,5 @@ /** - * Pure translation between harness vocabulary and ACP wire types. + * Pure, total translation between harness vocabulary and ACP wire types. * @module @deepseek-ai/dsh-acp/codec */ @@ -10,6 +10,11 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr /** * Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum. * + * `completed` and the defensive `error` case map to `end_turn`; + * `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map + * to `cancelled`. The bridge rejects error turns before this mapping. Unknown + * merge-extensible kinds use legal fallback `end_turn` rather than breaking + * the prompt RPC. * @param reason - the harness turn-end reason to translate. * @returns the legal ACP wire value per the mapping above. */ diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index c252e939a0..994eccd010 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -72,7 +72,7 @@ import { } from './codec.ts' export const name = 'acp' -// Interface services required by advertised ACP capabilities. +// Interface services required by advertised load, presentation, and interaction capabilities. export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] /** Build an ACP invalid-params error with visible human detail. */ @@ -80,7 +80,7 @@ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** Build an ACP internal error with visible human detail. */ +/** Build an ACP internal error with visible detail; plain handler errors are flattened on wire. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -570,8 +570,8 @@ export function apply(ctx: Context, config: AcpConfig): void { meta: { cwd: params.cwd }, agentOptions: agentOptions(config), }) - // Creation is now asynchronous because it awaits the unpublished setup - // transaction. A client disconnect can therefore close this bridge + // Creation awaits the unpublished setup transaction. A client disconnect + // can therefore close this bridge // after the entry check but before the handle resolves; never install a // post-close record that quiesce() could not have seen. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index 6b77f3ab49..e63658f12a 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -24,7 +24,8 @@ describe('acp bridge — disposal & HMR safety', () => { await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') - // A resolved teardown is the quiescence boundary. + // Teardown must abort and await the loop: once it resolves the agent is settled, and the + // hanging prompt itself completes as cancelled rather than remaining pending. await harness.ctx.fiber.dispose() expect(agent.status).not.toBe('running') @@ -33,7 +34,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => { - // An ACP-only unload must close creation while shared services remain live. + // Unload only the bridge while transport and shared services remain live. Its closed guard must + // reject late creation before an orphan agent can enter the registry. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -45,7 +47,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => { - // The caller fiber owns agents created through its traced service proxy. + // The traced service proxy binds loop registration to the caller (bridge) fiber. ACP-only + // disposal must therefore reclaim the agent even while agent-loop itself remains mounted. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -57,7 +60,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => { - // Assert registry state because the closed transport rejects the RPC. + // Disconnect sets the closed guard and severs the RPC, so registry state—not the rejection + // shape—proves a late request did not create an undriveable agent. const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const before = harness.ctx.agents.list().length @@ -69,7 +73,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => { - // Disconnect must dispose, not merely idle, the owned agent. + // Disconnect mid-stream must dispose, not merely idle, the owned agent; otherwise updates would + // be swallowed while a registered session survived without a client. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -83,7 +88,8 @@ describe('acp bridge — disposal & HMR safety', () => { await agent.whenIdle() expect(agent.status).toBe('disposed') - // The shared bridge teardown also removes registry state. + // Await the same memoized bridge teardown without removing root services. It must finish the + // AgentHandle teardown and remove both registry records, not just stop the loop. await harness.acpFiber.dispose() expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() @@ -91,7 +97,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => { - // Both teardown callers must await the same quiescence boundary. + // Transport close and fiber disposal can race. Both must await one memoized teardown; a guard + // based only on record removal could let the second caller return while the first still drains. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -122,7 +129,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => { - // Reload from storage to verify final flush precedes session detach. + // AgentHandle teardown stops and awaits the loop, flushes through still-attached store hooks, + // then detaches the session. Reloading verifies that order from durable state. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -141,7 +149,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => { - // A mid-turn dispose must flush its real closer before detaching storage. + // Here disposal itself makes the loop append `turn/end {disposed}` and flush. Reload must find + // that real closer, not crash recovery's synthetic `interrupted`, proving detach ran last. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -162,7 +171,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('per-session AgentHandle dispose leaves sibling agents untouched', async () => { - // Dispose one handle and assert the sibling remains published. + // A per-session handle owns exactly one agent and session. Dispose A and assert B remains fully + // published, which guards against context-wide teardown. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, @@ -184,7 +194,8 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => { - // Listener failure cannot skip the later session-detach disposer. + // Composite disposers run in sequence. A throwing `agent/disposed` listener must be contained or + // it would skip later session detach, leaking publication hooks and creating a durability hole. const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ @@ -201,12 +212,14 @@ describe('acp bridge — disposal & HMR safety', () => { }) it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => { - // Concurrent callers must share the in-flight teardown promise. + // The Cordis effect disposer is single-shot and would let a second call return after its epoch + // clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) - // Gate the final flush to keep teardown observably in flight. + // A hanging turn makes disposal produce a final flush; gate it so the second call arrives while + // teardown is observably in flight. handle.agent.send([{ type: 'text', text: 'go' }]) await new Promise(r => setTimeout(r, 30)) expect(handle.agent.status).toBe('running') diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 8926d34922..440120943f 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -1,5 +1,7 @@ /** - * Shared test fixtures for the ACP bridge specs. + * Shared non-spec fixture that mounts the full in-memory agent/persistence stack and connects the + * ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an + * editor without a subprocess or stdio. */ import { Context } from 'cordis' @@ -213,7 +215,8 @@ export async function makeBridgeHarness(options: { // Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow // to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent - // writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.) + // writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.) Holding the c2a + // writer lets tests EOF the agent reader and simulate editor disconnect. const a2c = new TransformStream() const c2a = new TransformStream() const c2aWriter = c2a.writable.getWriter() @@ -268,16 +271,19 @@ export async function makeBridgeHarness(options: { }, }) - // Wire the bridge (agent side) and the client (test side). + // Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no + // model and must survive the object spread. const cfg: AcpConfig = { stream: agentStream, ...options.config } if (!(options.config && 'model' in options.config)) cfg.model = 'mock' // Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the - // real `inject`), not `AcpPlugin.apply(ctx, cfg)` directly on the root ctx. + // real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run + // outside apply's injection scope, matching production and exposing missing-inject failures. harness.acpFiber = await ctx.plugin({ name: 'acp-test', // Use the bridge's real exported `inject` so this never drifts from the plugin's actual // dependency list (adding a service to the bridge must not require editing the harness — a - // hardcoded list silently broke when `tools` was added). + // hardcoded list silently broke when `tools` was added). The returned fiber permits ACP-only + // disposal while root services remain live for HMR assertions. inject: [...AcpPlugin.inject], apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) }, }) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index 16e32a7051..f57767fb38 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -58,7 +58,8 @@ describe('acp bridge — session/load replay', () => { }) it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => { - // A turn with a real bash tool call is persisted, then loaded by a fresh bridge. + // Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs + // call and result in log order so replay uses the shipping tool's same cards as live streaming. live = await makeBridgeHarness({ storageDir, withBash: true, @@ -90,7 +91,8 @@ describe('acp bridge — session/load replay', () => { }) it('replays a persisted todo/write as a plan sessionUpdate on load', async () => { - // A turn whose model called todo_write persists a todo/write event. + // A persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the + // current plan, not just the tool transcript. live = await makeBridgeHarness({ storageDir, withTodo: true, @@ -161,7 +163,8 @@ describe('acp bridge — session/load replay', () => { }) it('a load whose resume finishes after a client disconnect leaks no live session', async () => { - // A session/load is mid-resume() when the client transport closes. + // Stall persistence so transport closes while resume is pending. Whether the SDK rejects first + // or the bridge's post-await guard fires, no agent may survive for the dead connection. live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] }) await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -187,7 +190,8 @@ describe('acp bridge — session/load replay', () => { it('rejects load when the requested cwd does not match the persisted session cwd', async () => { // Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's - // launch dir. + // launch dir. Resume must retain the header cwd and route bash there rather than reject the + // mismatch or substitute the server cwd. loader = await makeBridgeHarness({ storageDir, script: [] }) const otherCwd = '/some/other/workspace' await loader.ctx.sessionPersistence.create({ @@ -223,7 +227,8 @@ describe('acp bridge — session/load replay', () => { }) it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => { - // A legacy / externally-created session log with no header.cwd. + // A legacy/external log without `header.cwd` must be rejected; the request cwd does not override + // it, and accepting would let bash silently fall back to the server launch directory. loader = await makeBridgeHarness({ storageDir, script: [] }) await loader.ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd diff --git a/packages/ui/acp/tests/properties.spec.ts b/packages/ui/acp/tests/properties.spec.ts index 614d3ca481..af661d0eed 100644 --- a/packages/ui/acp/tests/properties.spec.ts +++ b/packages/ui/acp/tests/properties.spec.ts @@ -1,7 +1,9 @@ /** * Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013 * precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure - * `streamSessionEventUpdate` translator and assert the invariants an ACP client relies on. + * `streamSessionEventUpdate` translator and assert legal update variants, call-before-result order + * per tool id, and deterministic event-to-update translation. Keeping this pure makes live and + * replay equivalence deterministic rather than a timing property. */ import { describe, expect, it } from 'vitest' diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index 6331147d37..613621f9c8 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -289,7 +289,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => { // A buggy tool whose display callbacks throw must not fail a live turn or a session/load - // replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). + // replay (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The + // presenter reports the error and falls back to generic rendering. const boom: ToolDefinition = { name: 'boom', description: 'b', @@ -622,7 +623,7 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo // Drive the SHIPPING fs edit tool through the bridge: the pending tool/call installs the // call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`, // which presentResult narrows into a `diff` result card the bridge forwards as `{ type: - // 'diff' }` content blocks. + // 'diff' }` content blocks. The real tool is required because its result metadata is the contract. async function fsCtx(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -676,7 +677,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => { // A `tool_call_update.title` replaces the card header, so the result-side diff must // relativize its title exactly as the pending card did — otherwise a completed - // absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. + // absolute-path edit flips `Edit src/b.ts` back to the raw absolute path. Diff and location + // paths remain absolute so the editor can open the real file. const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' }) @@ -698,7 +700,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo }) it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => { - // A synthetic empty diff covers branches shipping filesystem tools cannot emit. + // Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic + // tool is required to cover both absent-title and empty-content result branches. const emptyDiffTool: ToolDefinition = { name: 'writer', description: 'writes a file', @@ -725,7 +728,8 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => { // The bridge relativizes a file card's TITLE against the session workspace cwd (mirroring the - // reference adapter's toDisplayPath), while leaving locations/ diff paths RAW. + // reference adapter's `toDisplayPath`), while leaving location/diff paths raw. Use real fs tools + // and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd. async function fsCtx(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -777,7 +781,8 @@ describe('relative-path display titles (bridge relativizes the title against the it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => { // `/work/proj/..cache/x` is inside the workspace — its relative form `..cache/x` begins - // with the chars `..` but is not a parent segment. + // with the chars `..` but is not a parent segment. Segment-aware guarding must relativize it, + // matching targets under `cwd + sep` in the reference adapter. const ctx = await fsCtx() const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' }) expect((update as { title: string }).title).toBe('Read ..cache/x.ts') diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 9c7da399de..78591ffa76 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -124,7 +124,9 @@ describe('acp bridge — turn outcomes', () => { }) it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => { - // Terminal capability moves output to card metadata. + // With terminal output advertised, a real bash call emits description then terminal content + // plus cwd metadata; its result uses terminal output/exit metadata and omits text that would + // clobber the card. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -160,7 +162,8 @@ describe('acp bridge — turn outcomes', () => { }) it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => { - // Session creation snapshots the capability for both call and result. + // Create the session with terminal support, then disable it connection-wide. The session's + // snapshot must keep call and result rendering consistent instead of re-reading changed state. harness = await makeBridgeHarness({ storageDir, withBash: true, @@ -316,7 +319,9 @@ describe('acp bridge — turn outcomes', () => { }) it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => { - // The cancelled prompt must not leave queued work for another turn. + // JSON-RPC timing normally makes this a running mid-step cancellation; pre-step dropping is + // covered in agent-loop. Here the prompt must settle cancelled, return idle, and clear queued + // work so the scripted second response cannot leak into another turn. harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] }) const sessionId = await newSession(harness) const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -330,7 +335,8 @@ describe('acp bridge — turn outcomes', () => { }) it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => { - // Exercise cancel→prompt without an intervening quiescence wait. + // The bridge settles cancel synchronously, so exercise the production cancel→prompt race with + // no `whenIdle()`. An idle cancel must not mark or drop the following prompt. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) // Cancel while idle (no prompt in flight) — a no-op. @@ -346,7 +352,8 @@ describe('acp bridge — turn outcomes', () => { }) it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => { - // A cancel marker must not leak onto an immediate next prompt. + // Cancel a running turn and immediately send another prompt without awaiting quiescence. The + // cancellation marker belongs only to the first turn and must not drop the next request. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] }) const sessionId = await newSession(harness) const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] }) @@ -364,7 +371,8 @@ describe('acp bridge — turn outcomes', () => { }) it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => { - // Correlation must keep A's late turn/end from settling B. + // Cancellation frees A's slot before its aborted turn/end is appended. Send B in that window; + // correlation by turn number must prevent A's late closer from settling B as cancelled. harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] }) const sessionId = await newSession(harness) @@ -373,7 +381,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) expect((await a).stopReason).toBe('cancelled') - // B owns a later turn number than A. + // B owns the later turn and must complete on its own turn/end. const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] }) expect(b.stopReason).toBe('end_turn') const text = harness.updates diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index f5929ac363..a519b3cf70 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -11,8 +11,8 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' /** - * Resolve the config to boot, honoring snapshot replay. - * + * Resolve the config to boot. Replay swaps a `cordis.yml` basename for + * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. * @param configPath - the requested config path (absolute, or relative to `cwd`). * @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the * basename. @@ -62,8 +62,9 @@ export interface FailLoudProcess { } /** - * Make a load failure fail loud with a clear message on stderr. - * + * Install before boot to turn a late unhandled plugin-init rejection into one + * labelled stderr diagnostic and `exit(1)`. Stdout remains untouched for ACP; + * the returned function removes the handler. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @returns the uninstaller that removes the rejection handler. @@ -78,8 +79,9 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process } /** - * After the tree settles, assert every loader entry actually started. - * + * After the tree settles, reject entries with no fiber, which indicates a + * swallowed module-import failure. Disabled entries are the only valid + * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. */ @@ -92,9 +94,11 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } /** - * Boot the Loader against `absoluteConfigPath` and return the root context once the whole tree - * has settled. - * + * Boot the Loader against `absoluteConfigPath` and return only after the whole + * tree settles. The include uses an absolute file URL while `baseUrl` stays at + * the config directory for its relative imports. A missing fiber rejects here; + * a later init rejection is handled by {@link installFailLoud}. Built bins need + * `--expose-internals` for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index c16863b8cc..f3eaf5cf34 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node /** - * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that loads the {@link - * @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM adapter and a bash executor). + * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-agent [config]`, defaulting to the + * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in + * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs. * @module @deepseek-ai/dsh-stdio-agent/bin */ diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 9657298f09..809730396f 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -3,6 +3,9 @@ * coupled front-door cluster a terminal chat needs — a console logger, the readline UI (the * in-package `stdio-chat` module), JSONL session persistence, and a pre-created `main` agent * the UI drives. + * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This + * Loader plugin intentionally exposes named exports only; a default export + * would hide its `Config` schema (see docs/postmortem/0001). * @module @deepseek-ai/dsh-stdio-agent */ diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 01ffdb6a04..bed9e00f0f 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -1,6 +1,7 @@ /** - * The stdio app's readline UI: reads lines from stdin → `agent.send()`/ `steer()`, and renders - * the durable transcript to stdout. + * The stdio app's readline UI: reads lines from stdin into `agent.send()` or + * `steer()`, renders the durable event stream to stdout, and exits piped input + * only after submitted work reaches idle. * @module @deepseek-ai/dsh-stdio-agent/stdio-chat */ @@ -83,16 +84,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime - // Render label lookup: the `turn/start` session event carries only the turn number, so to - // print the short agent id (`[main turn 1]`) we map the session's id to its agent's id. + // Session ids need not equal agent ids. Seed existing agents before listening + // so a pre-created or HMR-surviving agent still gets its short render label. const labelBySession = new Map() for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) - // Transcript rendering off the durable `session/event` feed — the assistant token stream, - // turn/step boundaries, tool activity, and todos all come from the one canonical stream (no - // agent/* mirrors). + // Render the canonical append order from session/event so reasoning state is + // deterministic across chunks and boundaries; there are no agent/* mirrors. let inReasoning = false ctx.on('session/event', (session, event) => { if (event.type === 'assistant/chunk') { @@ -135,9 +135,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt ctx.effect(() => { const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) - // Piped-input exit, once stdin reaches EOF: - If no line ever submitted work (empty stdin, - // blank-only lines), exit immediately — no turn will ever start, so there is nothing to - // wait for. + // On piped EOF, exit immediately if no work was submitted. Otherwise wait + // for a real running state followed by idle: sends do not synchronously mark + // running, and several queued lines may share one turn. let stdinClosed = false let disposed = false let submittedWork = false @@ -155,7 +155,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const agent = ctx.agents.get(agentId) if (agent && agent.status !== 'idle') return // a turn is still running } - // Let any final output flush, then exit. + // Let final output flush; track the timer so re-entry coalesces and HMR + // disposal can cancel it before it exits the replacement process. if (exitTimer !== undefined) { return // exit already scheduled — coalesce re-entrant calls } diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 45b67527b9..ddd7ca454e 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -7,13 +7,17 @@ import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' /** - * Built-ARTIFACT smoke for the published `dsh-stdio-agent` bin. + * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and + * require the banner plus echo round-trip. This catches built-only early-exit and config-resolution + * failures masked by tsx source smokes. It skips before build; `--expose-internals` enables Cordis + * bare-plugin loading, matching the demo command. */ const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') -// Workspace packages the stdio app's tree needs, by repo-relative path. +// Symlink each required workspace package by package name so plain Node resolves its built `main`, +// matching an installed dependency rather than tsconfig paths. const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', @@ -32,8 +36,9 @@ async function pkgName(absDir: string): Promise { } /** - * Build a temporary symlinked consumer for the stdio app. The optional disabled - * broken entry verifies that load guards accept intentionally fiber-less entries. + * Build a temporary external consumer with built workspace/vendor links and a mock-backed config. + * The optional missing-but-disabled plugin verifies load guards accept intentionally fiber-less + * entries rather than treating them as import failures. */ async function makeConsumer(welcome: string, disabledBrokenEntry = false): Promise { const dir = await mkdtemp(join(tmpdir(), 'stdio-built-bin-')) @@ -130,7 +135,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. it('boots cleanly when the config disables an (otherwise unresolvable) entry', async () => { // A `disabled: true` entry settles without a fiber by design; the fail-loud entry-load - // guard must not mistake it for a failed import. + // guard must not mistake it for a failed import. The nonexistent path makes that distinction + // observable while the successful round-trip proves boot continued. consumer = await makeConsumer('DISABLED-OK ready.', true) const { stdout, code, stderr } = await runBuiltBin(consumer, './cordis.yml', 'echo hi') expect(stderr).not.toContain('failed to load') @@ -140,7 +146,8 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A consumer who typos the config path must get a clear failure, not silent success. + // A nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and + // boot's settled-entry guard must turn that state into a clear non-zero failure. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') expect(code).not.toBe(0) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index af5c640a28..6bbba0e492 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -10,9 +10,10 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' /** - * Unit coverage for the @deepseek-ai/dsh-stdio-agent app plugin: mounting it composes the - * console logger, the agent-core spine (pre-creating the `main` agent from the app config), - * the JSONL backend, and the readline UI in one `ctx.plugin`. + * Unit coverage for app composition and config forwarding: console logger, pre-created main agent, + * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the + * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise + * survive namespace collapse while silently losing its schema. */ async function mount(config: stdioAgent.Config): Promise { const ctx = new Context() @@ -152,7 +153,8 @@ describe('dsh-stdio-agent app', () => { }) it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => { - // Loader must retain the namespace so name, Config, and apply survive unwrapping. + // A default export would make `unwrapExports` collapse this inject-less namespace and silently + // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly. expect('default' in stdioAgent).toBe(false) expect(typeof stdioAgent.apply).toBe('function') diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 03deab34ac..f734acfd0d 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -181,7 +181,8 @@ describe('createStdioChat rendering', () => { it('seeds labels for agents already registered before the UI installs', async () => { // The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber) // fired its `agent/created` before the UI's listener existed, so the live listener alone - // would miss it. + // would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead + // of falling back to the raw session id. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) diff --git a/packages/ui/user-approval/README.md b/packages/ui/user-approval/README.md index b3c2f5563b..da37b7ea98 100644 --- a/packages/ui/user-approval/README.md +++ b/packages/ui/user-approval/README.md @@ -6,6 +6,6 @@ Each request must belong to an open agent turn. The service appends a paired `ap Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer. -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is exposed to the model through the prompt and a coalesced switch notice. +`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice whose header marker distinguishes user changes from operator/config changes. The tools pipeline consumes this seam for `ask` decisions and the sandboxed bash tool uses it for escalated retries. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 3225ac6336..6bd91037cc 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -171,7 +171,8 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean { } /** - * Append the sole durable representation of a session policy override. + * Append the sole durable representation of a session policy override. Invalid + * values throw before the log changes; consumers fold the new value on each read. * @param session - the session the override belongs to. * @param policy - the policy in effect until the next switch. */ diff --git a/packages/ui/user-approval/tests/approval.spec.ts b/packages/ui/user-approval/tests/approval.spec.ts index c07559663e..f248e5c468 100644 --- a/packages/ui/user-approval/tests/approval.spec.ts +++ b/packages/ui/user-approval/tests/approval.spec.ts @@ -438,7 +438,7 @@ describe('approval policy (the approval/policy fold)', () => { it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => { // Cordis prepend unshifts ahead of every existing listener, including any gate LISTENER the // service could register — which is exactly why the 'never' decision lives inside request() - // instead. + // instead. This eager grant would bypass a listener-based gate and therefore must never run. const ctx = new Context() await ctx.plugin(ApprovalService, { policy: 'never' }) const consulted = vi.fn() diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index 6377c2b7ef..e7c89e831d 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -21,4 +21,4 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 6f2af0de07..0e880c52da 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -1,6 +1,9 @@ /** - * The `Branded` nominal-typing primitive — a type-only utility (no runtime code, no - * harness-package dependency) shared by every package that owns a cross-boundary id. + * Dependency-free nominal typing for cross-boundary identifiers. Structurally identical runtime + * strings become non-interchangeable statically while retaining ordinary comparison, logging, and + * serialization. Each owning package defines its concrete id and zero-cost factory; brand ids that + * can plausibly be confused across packages, not arbitrary strings. This package exports only the + * erased primitive so an owner need not depend on another capability package. * @module @deepseek-ai/dsh-brand */ diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index feb332e5d8..47c5d87c84 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -1,7 +1,7 @@ /** - * The timing-and-classification half of a timeout — a zero-dependency library of pure - * functions shared by every capability that clamps a caller's timeout hint, arms a deadline, - * and later has to tell "timed out" apart from "cancelled". + * Shared timeout arithmetic, signal fusion, and classification. The library + * only notifies through abort signals; each capability still owns the mechanism + * that stops its work and translates timeout reasons into public outcomes. * @module @deepseek-ai/dsh-timeout */ @@ -22,15 +22,16 @@ export class TimeoutReason extends Error { } /** - * Validate a caller's optional timeout hint, fill it from the backend default, then cap at - * the backend max. + * Validate a caller's optional timeout hint, use the backend default, then cap + * it. Supplied values must be positive and finite; zero is not a public + * disable-timeout sentinel. * * @param requested The caller's optional hint; validated when present. * @param def The backend default applied when `requested` is absent. * @param max The backend upper bound the result is capped to. * @param name Field name used in the thrown message (so the caller sees which input was * bad). - * @returns The effective timeout in milliseconds: `min(requested ?? + * @returns The effective timeout in milliseconds: `min(requested ?? def, max)`. */ export function clampTimeout( requested: number | undefined, @@ -53,9 +54,9 @@ export interface Deadline { } /** - * Build a deadline signal that aborts on upstream cancellation OR on timeout, with the - * timeout carrying an identifiable {@link TimeoutReason} (unlike native - * `AbortSignal.timeout()`, whose fixed `TimeoutError` is opaque). + * Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is + * the internal no-timer sentinel; the returned disposer clears an armed timer. + * The signal only notifies, so callers must stop their own work. * * @param upstream The caller's cancellation signal, if any, fused into the result. * @param timeoutMs Deadline in milliseconds; `<= 0` means "no timeout" (arm no timer). @@ -85,8 +86,9 @@ export function deadline( } /** - * Recover the {@link TimeoutReason} from an aborted signal (or any object with a `reason`), - * else `undefined`. + * Recover a timeout reason from a reason-bearing object. Supplying `code` + * distinguishes this deadline from a nested upstream deadline; a foreign code + * follows the ordinary cancellation path. * * @param x An {@link AbortSignal} or any `{ reason }` carrier (e.g. a caught abort error). * @param code When provided, only a {@link TimeoutReason} with this exact `code` matches. diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index dba7e58fb4..dd4da3adde 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -172,7 +172,8 @@ describe('timeoutOf', () => { describe('deadline — nested deadlines', () => { it("does not misclassify an outer deadline's timeout as the inner code", () => { // The upstream handed to the inner deadline is ITSELF a deadline that has already timed out - // (outer). + // (outer). `AbortSignal.any` preserves that reason, but scoping `timeoutOf` to the inner code + // must classify it as upstream cancellation rather than the inner capability's timeout. const outer = new AbortController() outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30)) using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT') diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index f5b823f5aa..78dde7b79e 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -1,8 +1,8 @@ /** - * The model-facing `web_fetch` tool: retrieve the content of a specific URL. Execution goes - * through `ctx.web` — this module owns the model-facing schema, argument validation, and - * PRESENTATION (HTML→markdown, truncation formatting), while the fetch provider owns safe - * retrieval (transport, redirects, caps). + * The model-facing `web_fetch` tool. This module owns its schema, validation, and presentation; + * `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes + * `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting + * signal. A provider timeout remains a backstop for direct seam callers. */ import type { Context } from 'cordis' diff --git a/packages/web/tool-web/src/html.ts b/packages/web/tool-web/src/html.ts index de4e3e3cf7..1d6ffdb9a3 100644 --- a/packages/web/tool-web/src/html.ts +++ b/packages/web/tool-web/src/html.ts @@ -1,5 +1,8 @@ /** - * Minimal, dependency-free HTML→markdown-ish text conversion for `web_fetch` presentation. + * Minimal dependency-free HTML-to-readable-text conversion for `web_fetch`, not a full parser. It + * removes non-content elements and tags, decodes common entities, collapses whitespace, and keeps + * basic headings, lists, and links. A richer converter can replace it without changing the seam or + * tool schema. * @module @deepseek-ai/dsh-tool-web/html */ diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 7867a33d57..7096371ed1 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -1,7 +1,8 @@ /** - * The model-facing web tool suite (`web_search`, `web_fetch`) over the `ctx.web` seam. This - * root plugin registers the tools the product has ENABLED, composing the per-tool registration - * helpers (`applyWebSearchTool`, `applyWebFetchTool`). + * Model-facing `web_search` and `web_fetch` tools over `ctx.web`. This package owns schemas, + * validation, prompt guidance, limits, and presentation, never concrete providers. Enablement + * controls tool registration; an enabled tool remains visible when its provider is unavailable + * and fails with a structured error at execution time. * @module @deepseek-ai/dsh-tool-web */ diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 1359828623..280c96b5bd 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -2,7 +2,8 @@ * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search provider * (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the * tool-call timeout policy (`dsh-timeout-policy`), exercised through `ctx.tools.execute()` — - * nothing bypasses the tool registry. + * nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP; search + * uses the real Exa provider with only its network boundary stubbed. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -157,7 +158,8 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => { // A direct seam caller does not go through tools/execute, so the tool-call policy never - // applies; the provider's own timeout is the only budget. + // applies; the provider's own timeout is the only budget. A short request hint must therefore + // produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`. const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( () => undefined, (e: unknown) => e as { code?: string }, diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts index 74b4f2fe1d..1df718b871 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -1,4 +1,9 @@ -/** Real Loader-path coverage for the namespace plugin's export shape. */ +/** + * Real Loader-path guard for an injected namespace plugin. A default export would make + * `unwrapExports` collapse the namespace and drop `inject`, causing access to `ctx.web` to fail. + * Hand-built mounting bypasses that path, so this test unwraps through the real Loader first; see + * postmortem 0001. + */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' @@ -28,6 +33,7 @@ describe('dsh-tool-web real-load-path guard', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(toolWeb) as Parameters[0] + // Mounting the collapsed shape would throw for missing injection here. const fiber = await ctx.plugin(unwrapped) expect(ctx.tools.schemas().map(s => s.name)).toEqual(expect.arrayContaining(['web_search', 'web_fetch'])) await fiber.dispose() diff --git a/packages/web/web-fetch-local/README.md b/packages/web/web-fetch-local/README.md index 9c2ef0030f..0ed2a12aa6 100644 --- a/packages/web/web-fetch-local/README.md +++ b/packages/web/web-fetch-local/README.md @@ -8,7 +8,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource. -The provider's `timeoutMs`/`maxTimeoutMs` is a **resource backstop** for direct `ctx.web.fetch()` callers and misconfigured deployments — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed. +The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`. + +A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed. ## Transport hygiene diff --git a/packages/web/web-fetch-local/src/provider.ts b/packages/web/web-fetch-local/src/provider.ts index 5c419e7ba1..69f0509d64 100644 --- a/packages/web/web-fetch-local/src/provider.ts +++ b/packages/web/web-fetch-local/src/provider.ts @@ -1,7 +1,10 @@ /** - * `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public HTTP(S) URL with - * platform-native `fetch` at the repo's Node floor and returns a status code plus bounded - * decoded content. + * Safe HTTP(S) retrieval for `ctx.web`: validates URLs, follows only same-origin redirects, + * enforces time and size limits, classifies and decodes text, and leaves presentation to + * `@deepseek-ai/dsh-tool-web`. Requests carry no browser cookies or ambient credentials. + * + * Private-network and SSRF protection is not implemented; do not enable this provider where + * it can reach sensitive internal targets. * @module @deepseek-ai/dsh-web-fetch-local/provider */ @@ -46,8 +49,8 @@ export class LocalFetchProvider implements WebFetchProvider { if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED') const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs) - // One deadline signal fuses the caller's abort with our own timeout, so the network request - // and the streaming read both stop on either. + // One signal stops both the request and body read. The deadline's TimeoutReason later + // distinguishes this provider's timeout from caller or outer-deadline cancellation. using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT') return await this.followAndRead(request.url, d.signal) } diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 41b000d26a..e73c2fc4c9 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -33,4 +33,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: ## Mapping -DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` is built from the `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, `publishedAt` ← `page_age`. The per-source `snippet` lives separately in a `text` block's `citations[]` (a `cited_text` keyed by `url`), so the provider joins the two — a result with no citation excerpt simply has no `snippet`. Results are deduped by `url` (a `maxUses > 1` request can surface the same URL across searches). DeepSeek's `web_search` has no result-count knob (only `maxUses`), so `maxResults` is enforced by the seam (truncating `sources[]` and setting `truncated`). Provider failures surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. +DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, and `publishedAt` ← `page_age`. Snippets live separately as URL-keyed `cited_text` entries in a text block's `citations[]`; the provider joins them, leaving `snippet` absent when no excerpt exists. + +Results are deduplicated by URL because one request may surface the same page across searches. DeepSeek exposes `maxUses`, not a result-count knob, so the seam enforces `maxResults` by truncating `sources[]` and setting `truncated`. + +Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index b85d8406da..9666dde720 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -1,6 +1,7 @@ /** - * `@deepseek-ai/dsh-web-search-deepseek`: registers a DeepSeek-backed `WebSearchProvider` with - * `ctx.web`. + * Register a DeepSeek-backed provider in `ctx.web`. It calls the Anthropic-compatible Messages API + * with native `web_search_20250305`. The provider reuses `DEEPSEEK_API_KEY` but not + * `DEEPSEEK_BASE_URL`, because search and chat-completions use different bases. * @module @deepseek-ai/dsh-web-search-deepseek */ diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 6e9a34a33b..c972a0f8c0 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -1,6 +1,8 @@ /** - * `DeepSeekSearchProvider`: a `WebSearchProvider` backed by DeepSeek's Anthropic-compatible - * Messages API with the native `web_search_20250305` server tool enabled. + * DeepSeek search through an Anthropic-compatible Messages model call with the native + * `web_search_20250305` server tool. Each search costs a model turn, but returns structured + * result blocks; absence of those blocks is an error rather than a prose-scraping fallback. + * The wire format and native `fetch` client are provider-private and do not use `ctx.llm`. * @module @deepseek-ai/dsh-web-search-deepseek/provider */ @@ -94,6 +96,7 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map { }) it('survives the real Loader unwrapExports path keeping name/inject/Config', () => { - // A default export would make Loader discard the required web injection metadata. + // A default export would make `unwrapExports` collapse the namespace and drop `inject: ['web']`. + // Drive the real Loader path because hand-built namespace mounting cannot expose that failure. const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(deepseekPlugin) as Record expect(unwrapped).toBe(deepseekPlugin) diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts index 7c7bf64002..e4eb620aba 100644 --- a/packages/web/web-search-exa/src/provider.ts +++ b/packages/web/web-search-exa/src/provider.ts @@ -1,6 +1,8 @@ /** * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with - * highlight contents). + * highlight contents). It maps the first non-blank highlight to `snippet`, maps + * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content` + * because Exa returns no generated answer. * @module @deepseek-ai/dsh-web-search-exa/provider */ diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts index be8a90c44d..d5a58bf656 100644 --- a/packages/web/web-search-perplexity/src/provider.ts +++ b/packages/web/web-search-perplexity/src/provider.ts @@ -1,6 +1,8 @@ /** - * `PerplexitySearchProvider`: a `WebSearchProvider` backed by the Perplexity search API (an - * OpenAI-compatible `POST /chat/completions`). + * Perplexity search over its OpenAI-compatible chat-completions endpoint. The generated answer + * becomes `content`; sources prefer structured `search_results[]` and fall back to URL-only + * `citations[]`. The wire format and native `fetch` client are provider-private and do not use + * `ctx.llm`. * @module @deepseek-ai/dsh-web-search-perplexity/provider */ diff --git a/packages/web/web-search-perplexity/src/types.ts b/packages/web/web-search-perplexity/src/types.ts index a71f805aaa..9e47fda8c6 100644 --- a/packages/web/web-search-perplexity/src/types.ts +++ b/packages/web/web-search-perplexity/src/types.ts @@ -1,6 +1,7 @@ /** * Wire types for the Perplexity search API (`POST https://api.perplexity.ai/chat/completions`, - * an OpenAI-compatible chat shape). + * an OpenAI-compatible chat shape). Results prefer structured `search_results` and fall back to + * URL-only `citations`; the provider-private wire shape does not depend on `ctx.llm`. * @module @deepseek-ai/dsh-web-search-perplexity/types */ diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index bda6b00788..1775b0a04a 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -1,9 +1,8 @@ /** - * The web access seam (`ctx.web`): a provider registry plus a provider-selecting execution - * surface for two capabilities — search and fetch. Provider packages register concrete - * backends with `registerSearchProvider` / `registerFetchProvider`; the model-facing consumer - * (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and routes on the - * structured {@link WebError} codes selection throws. + * The web access seam (`ctx.web`): registries and provider-selecting execution for search and + * fetch. Duplicate ids are rejected. At execution time, a configured provider must exist and + * be usable; without one, exactly one usable provider is required, so selection never depends + * on registration order. * @module @deepseek-ai/dsh-web */ diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index de1e1517db..b43fb010d5 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,7 +1,7 @@ /** - * Vocabulary for the web capability seam (`ctx.web`): the search/fetch request/result shapes - * providers produce and consumers format, the provider status discriminant selection reads, - * the execution-control context, and the typed error taxonomy. + * Vocabulary for the web capability seam (`ctx.web`). Search and fetch deliberately share one + * seam so provider selection, cancellation, errors, and product configuration have one owner, + * while retaining separate request and result shapes. * @module @deepseek-ai/dsh-web/types */ @@ -150,9 +150,11 @@ export interface WebFetchProvider { } /** - * Typed web error. Extends {@link HarnessError} so it carries a stable, machine-routable - * `code` (a `string`, like every other seam's error) and chains `cause`. - * `ToolRegistry.execute()` converts a thrown `WebError` into an error tool result whose - * structured metadata exposes the code, so callers (hooks, tests, UI) route on it. + * Typed web error with a machine-routable, open-string `code` and chained `cause`. + * Consumers must tolerate provider-specific codes. Shared codes cover unavailable, + * missing, unusable, ambiguous, or duplicate providers, cancellation, and provider failure; + * the local fetch provider additionally distinguishes invalid or blocked URLs, redirects, + * size and timeout limits, and unsupported content types. Tool execution exposes the code in + * structured error metadata. */ export class WebError extends HarnessError {} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 29d00f7a2b..33f2939164 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -3,7 +3,10 @@ * subagents, and return the script's final value. Pure schema + lifecycle shaping — script * parsing, execution, caps, and cancellation live behind `ctx.workflows` * (`@deepseek-ai/dsh-workflow`), so a hardened engine swaps in without touching what the model - * sees. + * sees. Execution awaits `run.result` and always disposes the run; non-completed reasons become tool + * errors, and background collection remains deferred. Presentation is an args-only generic card + * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt + * section rather than deployment persona prose. * @module @deepseek-ai/dsh-tool-workflow */ @@ -169,7 +172,8 @@ export function apply(ctx: Context, config: Config): void { }) // Bridge the tool's abort signal to the run: if the parent step is aborted while the - // script is in flight, cancel the whole run. + // script is in flight, cancel the whole run. The signal also enters the engine directly, but + // this local bridge preserves the tool contract even if an implementation ignores it. const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal?.addEventListener('abort', onAbort, { once: true }) // `addEventListener` does NOT fire for a signal already aborted before diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 0ac608c83b..5caffca5ac 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -224,10 +224,8 @@ describe('dsh-tool-workflow', () => { describe('composition with the REAL worker-thread engine (the mock above must stay honest)', () => { it('an abort releases the tool even when the script parks on a promise no hook owns', async () => { - // Regression for the review-found turn wedge: the tool awaits run.result before its - // disposing finally, the registry and the loop await the tool — so if cancellation could - // not settle result (a script parked on `await new Promise(() => {})`), an aborted turn - // stayed wedged forever. + // The tool and loop await run.result before cleanup, so cancellation must settle a script + // parked on an unowned promise. Exercise that guarantee through the real registry and worker. const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 52b554fb55..29abe7b4b7 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -1,6 +1,8 @@ /** - * Host side of one workflow run. Owns the worker, child RPC, first-outcome - * settlement, cancellation grace, lifecycle pairing, and quiescent cleanup. + * Host side of one workflow run. The first worker result, unexpected death, or + * cancellation-grace expiry owns settlement and closes message admission. + * Pending starts share one abort signal; published children share idempotent + * cleanup, and quiescence waits for both while synthesizing any missing end events. * @module @deepseek-ai/dsh-workflow-workerthread/host */ diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index e0db2c459a..44ab91f465 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -1,5 +1,8 @@ /** - * The `node:worker_threads` workflow engine: the {@link WorkflowService} implementation. + * Worker-thread workflow engine. Each run executes its model-written script in + * an escapable vm context on a fresh worker and bridges `agent()` calls to host + * subagents. The thread prevents synchronous script work from blocking the host + * and permits forced termination, but it is containment rather than a security boundary. * @module @deepseek-ai/dsh-workflow-workerthread */ diff --git a/packages/workflow/workflow-workerthread/src/meta.ts b/packages/workflow/workflow-workerthread/src/meta.ts index 1ed1659d3c..c223be28fa 100644 --- a/packages/workflow/workflow-workerthread/src/meta.ts +++ b/packages/workflow/workflow-workerthread/src/meta.ts @@ -1,6 +1,8 @@ /** * Meta validation: check the caller-provided {@link WorkflowMeta} DATA against the shape - * contract and reject everything else loud, every violation named. + * contract and reject everything else loud, every violation named. Meta arrives as schema-checked + * JSON data, never evaluated script text; evaluating it on the host could run getters outside the + * worker timeout that exists to isolate model-written code. * @module @deepseek-ai/dsh-workflow-workerthread/meta */ diff --git a/packages/workflow/workflow-workerthread/src/protocol.ts b/packages/workflow/workflow-workerthread/src/protocol.ts index 033c8b98c2..b981447ed4 100644 --- a/packages/workflow/workflow-workerthread/src/protocol.ts +++ b/packages/workflow/workflow-workerthread/src/protocol.ts @@ -1,7 +1,9 @@ /** * The host⇄worker wire protocol: one string-valued enum of message tags per direction, a * payload map giving each tag its parameters (the single source of truth), and the message - * unions derived from them. + * unions derived from them. Payloads are plain JSON by construction for structured clone. Both + * directions are closed engine protocols whose receivers use `assertNever`; generic typed senders + * make tag/payload mismatches compile-time errors rather than silently skipped messages. * @module @deepseek-ai/dsh-workflow-workerthread/protocol */ diff --git a/packages/workflow/workflow-workerthread/src/realm.ts b/packages/workflow/workflow-workerthread/src/realm.ts index 9c2790249e..b16e2f6860 100644 --- a/packages/workflow/workflow-workerthread/src/realm.ts +++ b/packages/workflow/workflow-workerthread/src/realm.ts @@ -1,6 +1,10 @@ /** - * The engine's value boundary: copy script-realm values into plain JSON data — loud about - * everything JSON cannot carry — and render thrown script values to failure text. + * Materializes values leaving the script vm into plain JSON before they cross the worker + * boundary, and renders thrown script values without rejecting the run. The walk rejects + * lossy JSON shapes but trusts model-written workflow scripts: getters and proxy traps may + * run, and the vm is not a security boundary. The worker provides host-loop isolation and + * forced termination, not hostile-value containment. See + * docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md for the isolation rationale. * @module @deepseek-ai/dsh-workflow-workerthread/realm */ @@ -48,11 +52,16 @@ function hasPlainPrototype(value: object): boolean { } /** - * Copy `value` (typically from the vm realm) into plain host JSON data. + * Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is + * returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail + * with the offending path. Property accessors run normally, and a throwing read is wrapped + * with its rendered failure. * * @param value - the realm value to materialize. * @param root - the path label for the root value (error messages). * @returns the host-realm copy (plain objects/arrays/scalars only). + * @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic + * prototypes, or property reads that throw. */ export function materializeFromRealm(value: unknown, root = 'value'): unknown { if (value === undefined) return undefined diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 6905c51e85..a99ce495a4 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -1,7 +1,14 @@ /** - * Worker-side workflow runtime: vm hooks, child RPC, limits, value - * materialization, cancellation, and result shaping. Host termination enforces - * the cancellation deadline. + * Per-run worker-side vm hooks, child RPC, concurrency/caps, cancellation, and result shaping; it + * never touches Cordis. Script values leaving the realm are materialized as plain JSON before + * messaging. Values entering the trusted model-written realm are passed directly; `args` alone is + * cloned so script mutation cannot alter initialization data. See `./realm.ts` for the trust model. + * + * Fatal workflow errors—bad hook arguments, unsupported schemas/options, caps, start failures, and + * cancellation—propagate through combinators. Only child failures and ordinary stage errors become + * per-item nulls. Every returned promise has a rejection consumer so dropped script promises cannot + * kill the worker. A cancelled script that never settles emits nothing; the host force-settles the + * run within grace and terminates the thread. * @module @deepseek-ai/dsh-workflow-workerthread/runtime */ diff --git a/packages/workflow/workflow-workerthread/src/session.ts b/packages/workflow/workflow-workerthread/src/session.ts index f15b42c318..7fadb45c98 100644 --- a/packages/workflow/workflow-workerthread/src/session.ts +++ b/packages/workflow/workflow-workerthread/src/session.ts @@ -1,7 +1,13 @@ /** * The worker-side half of the engine: {@link runWorkerSession} wires one MessagePort to one * {@link WorkflowExecution} — hook progress and child starts go out as messages, run control - * and child lifecycle come back in — and posts the run's terminal result exactly once. + * and child lifecycle come back in — and posts the run's terminal result exactly once. Keeping it + * separate from `worker.ts` lets unit tests drive the session over a MessageChannel, because main + * process coverage cannot observe code inside a real Worker. + * + * The session announces ready and waits for `go`, so cancellation racing startup can prevent even + * the script's synchronous prefix. A cancel in place of `go` releases the gate into a cancelled + * drive without executing the body. * @module @deepseek-ai/dsh-workflow-workerthread/session */ @@ -127,8 +133,9 @@ export function requireParentPort(port: MessagePort | null): MessagePort { /** * Run one workflow script to settlement against `port`, posting the terminal result message * exactly once; resolves after that post (stray children may still be winding down through the - * port — the host owns their teardown and ultimately terminates the thread). - * + * port — the host owns their teardown and ultimately terminates the thread). It never rejects: + * constructor failure becomes an error result. Host pre-parse makes syntax failure here a likely + * Node-version skew, but the session still reports it instead of dying silently. * @param port - the channel to the host (the real `parentPort`, or one side * of an in-process `MessageChannel` in tests). * @param init - the run payload the host provided as `workerData`. diff --git a/packages/workflow/workflow-workerthread/src/types.ts b/packages/workflow/workflow-workerthread/src/types.ts index 249daaa986..6adb422b2a 100644 --- a/packages/workflow/workflow-workerthread/src/types.ts +++ b/packages/workflow/workflow-workerthread/src/types.ts @@ -1,6 +1,7 @@ /** * Non-protocol wire vocabulary for the worker-thread engine: the `workerData` init payload and - * the child-port interfaces the worker-side runtime consumes. + * the child-port interfaces the worker-side runtime consumes. Host/worker messages are defined in + * `./protocol.ts`; transported child requests and results are plain JSON for structured clone. * @module @deepseek-ai/dsh-workflow-workerthread/types */ diff --git a/packages/workflow/workflow-workerthread/src/worker.ts b/packages/workflow/workflow-workerthread/src/worker.ts index 0deb964883..f204ff0493 100644 --- a/packages/workflow/workflow-workerthread/src/worker.ts +++ b/packages/workflow/workflow-workerthread/src/worker.ts @@ -1,5 +1,7 @@ /** - * The worker-thread entry the engine spawns: bootstrap ./session.ts on the real `parentPort`. + * Single-statement worker entry that boots `runWorkerSession` on real `parentPort`. Logic remains in + * the session module for in-process MessageChannel coverage; importing this entry on the main thread + * exercises `requireParentPort`'s failure path. * @module @deepseek-ai/dsh-workflow-workerthread/worker */ diff --git a/packages/workflow/workflow-workerthread/tests/session.spec.ts b/packages/workflow/workflow-workerthread/tests/session.spec.ts index 5fc85ce647..07a9bac8d0 100644 --- a/packages/workflow/workflow-workerthread/tests/session.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/session.spec.ts @@ -437,10 +437,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { void runWorkerSession(host.port, init("return await agent('p')")) await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildStart).length).toBe(1) }) const callId = host.ofType(WorkerToHostType.ChildStart)[0]!.callId - // Cancel FIRST, then the (stale) started reply: the worker processes them - // in order, so the agent() continuation resumes already-cancelled — the - // window the real host cannot produce (it refuses starts once cancelled) - // but a teardown race can. + // Simulate a teardown race by delivering cancellation before a stale start reply. host.send({ type: HostToWorkerType.Cancel, reason: 'raced the start' }) host.send({ type: HostToWorkerType.ChildStarted, callId, childId: 'child-0' }) const result = await host.result() @@ -448,7 +445,7 @@ describe('runWorkerSession over an in-process MessageChannel', () => { await vi.waitFor(() => { expect(host.ofType(WorkerToHostType.ChildDispose).map(m => m.callId)).toContain(callId) }) - // The child never became an agent-start: it was wound down pre-lifecycle. + // The unpublished child is disposed without a lifecycle announcement. expect(host.ofType(WorkerToHostType.AgentStart)).toEqual([]) host.close() }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 1862026a93..e85bec9375 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -19,7 +19,11 @@ function fakeParent(): Agent { // Allow cold worker startup on contended CI runners. vi.setConfig({ testTimeout: 30_000 }) -/** Retry an assertion until it passes or the timeout elapses. */ +/** + * Wait up to 10 seconds for CPU-bound worker startup or cross-thread delivery on contended CI. + * Host reactions after an observed event use explicit tight overrides, so this generous startup + * allowance cannot hide multi-second reap regressions. + */ function waitFor(assertion: () => void, timeout = 10_000): Promise { return vi.waitFor(assertion, { timeout, interval: 50 }) } diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index ba366a8c66..95876050fd 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -101,7 +101,12 @@ export type WorkflowEventName = | 'workflow/agent-end' | 'workflow/end' -/** Machine-routable fatal workflow failures. Child-run failures are not codes. */ +/** + * Machine-routable fatal workflow failures: parse/meta/argument/schema errors, + * resource caps, subagent infrastructure failures, unserializable boundary + * values, and cancellation. An ordinary child failure resolves its item to + * `null` and is not one of these fatal codes. + */ export type WorkflowErrorCode = | 'SCRIPT_PARSE' | 'META_INVALID' diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index e4a17b56a2..5a00cfbb6d 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -106,7 +106,10 @@ export interface WorkflowResult { } /** - * The handle the consumer holds while a script executes. + * Holder-owned live workflow. `result` never rejects and settles within the + * engine's cancellation grace; failures resolve through `stopReason`. Consumers + * may cancel and must call idempotent `dispose()` on every path to await bounded + * script settlement and child quiescence. */ export interface WorkflowRun { readonly id: WorkflowRunId diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 4fb22ea32b..1f0bd2f930 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -175,8 +175,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } /** - * Enforce the packages/ hierarchy SHAPE: every package lives at exactly - * `packages//`. + * Enforce `packages//`: groups are open-named containers without a + * package.json, and packages may be neither flat nor more deeply nested. */ function checkHierarchyShape(): string[] { const errors: string[] = [] diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 1ccdac84f6..87be5853e7 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,6 +1,7 @@ /** - * Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Both require a - * DeepSeek API key; unsupported arguments fail with usage. + * Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Each overlay + * includes its base example, selects Code Mode, and adds the worker runtime. + * Both require a DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 06d018b589..dfcbbf844c 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -11,7 +11,11 @@ import ts from 'typescript' const root = resolve(import.meta.dirname, '..') -/** Classification of a TypeScript fence and the gate that owns it. */ +/** + * TypeScript-fence ownership. `check` compiles; `ignore` is an unchecked sketch + * counted in the opt-out ratio; the catalog and type-equivalence variants are + * excluded from that ratio because their owning gates verify them. + */ type BlockKind = 'check' | 'ignore' | 'type-equiv' | 'cordis-catalog' | 'persistence-catalog' | 'config-catalog' /** One extracted code block. */ @@ -101,10 +105,8 @@ files.sort() const all = files.flatMap(extractBlocks) const checked = all.filter(b => b.kind === 'check') const ignored = all.filter(b => b.kind === 'ignore') -// `type-equiv`, `cordis-catalog`, and `persistence-catalog` blocks are verified elsewhere -// (verify-type-equiv.ts and each catalog generator's `--check` freshness gate), not here: -// neither compiled nor counted toward the opt-out ratio (each is a separate fully-checked -// category, not an unchecked sketch). +// Only compile-eligible fences belong in the opt-out ratio; every other skipped +// kind has an independent verifier named in BlockKind's contract above. const ratioDenominator = checked.length + ignored.length if (checked.length === 0) { diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index e718c94edd..b5d2fa53d9 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -1,8 +1,10 @@ /** * Generate `docs/config-catalog.md` from package entry points, config types, * JSDoc, and static Schemastery schemas. Every package must classify, referenced - * types must resolve without collisions, and schema paths must exist on the - * declared config type. `--check` verifies the committed artifact. + * types must resolve without collisions, and every enumerable schema path must + * exist on the declared config type. External and dynamic shapes stay unknown; + * declared runtime-only fields need not appear in the schema. `--check` verifies + * the committed artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -293,7 +295,7 @@ function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: Type entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache) } catch { // A workspace package without a readable entry is reported by its own - // classification pass; for a lookup it is merely out of reach. + // classification pass; for a lookup it is out of reach. return 'unknown' } return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown' @@ -312,8 +314,9 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul */ function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set): PathLookup { if (steps.length === 0) return 'found' - // Guard recursion at NAMED declarations only — the sole way a walk can loop (a recursive - // interface/alias). + // Guard only named declarations, where recursive types can loop. Structural + // children can share a source position with their parent, so guarding them + // would mistake ordinary descent for a cycle. if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) { const key = `${ctx.abs}:${node.pos}:${steps.length}` if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop @@ -713,8 +716,8 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { } } - // Second phase: fold composed schemas' key paths in, then walk every schema-validated path - // against the declared config type. + // Fold composed schemas' key paths in, then check each path against the type. + // Only a definite miss fails; shapes the walk cannot enumerate stay unknown. const byName = new Map(entries.map(e => [e.pkg, e])) for (const entry of entries) { if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index 331821b8ff..cc87c379e9 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -1,7 +1,8 @@ /** * Generate the model-facing Cordis API data module from the same event/service - * collector as the documentation catalogs. Output includes concise docs, - * signatures, and referenced public type shapes; `--check` verifies freshness. + * collector as the documentation catalogs. It emits first-sentence docs, raw + * signatures, transitive public type shapes, and inherited context entries, + * without source pointers; output is deterministic and `--check` verifies it. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -27,7 +28,10 @@ function quote(value: string): string { return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'').replace(/\n/g, '\\n')}'` } -/** Collect uniquely named exported interface and type shapes. */ +/** + * Collect exported interface and type shapes; omit names declared in multiple + * packages rather than risk serving the wrong package's shape. + */ function collectTypeDecls(scanRoot: string = root): Map { const printer = ts.createPrinter({ removeComments: true }) const decls = new Map() @@ -53,7 +57,7 @@ function collectTypeDecls(scanRoot: string = root): Map { return decls } -/** Resolve the transitive public type shapes referenced by seed text. */ +/** Resolve and sort the word-bounded transitive type closure referenced by seed text. */ function referencedTypes(seeds: string[], decls: Map): { name: string; declaration: string }[] { const included = new Map() let frontier = seeds diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0bc8a31039..a32107b53b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -18,7 +18,11 @@ const OUT_SERVICES = 'docs/cordis-catalog/services.md' * doc-typecheck, since a bare signature fragment is not standalone-compilable). */ const FENCE = 'ts cordis-catalog' -/** Primary core-data-structures page for signature types shared by both catalog generators. */ +/** + * One primary core-data-structures page per signature type, shared by the + * Cordis and config catalogs; union names intentionally do not reuse the + * type-equivalence manifest's map-symbol entries. + */ // TODO(catalog-type-links): verify or generate link-map coverage. export const LINK_MAP: Record = { Agent: 'core.md', @@ -157,7 +161,8 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) } if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) - // Payload parameters need a non-empty @param each. + // Payload parameters need a non-empty @param. The `this` receiver is not + // payload, and a waterfall's trailing `next` is covered by its mode. const { params } = parseTags(raw) checkParams(where, 'event', member.parameters, params, sf, p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) @@ -208,7 +213,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue - // Only the PUBLIC callable surface a `ctx.` consumer sees. + // Only instance methods callable through `ctx.` are surface; + // private, protected, and static methods are not. const nonPublic = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index b0c8a57758..18ab5c50be 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1,5 +1,8 @@ /** - * Generate (and verify) the relationship-diagram docs. + * Generate the relationship layer above the module, Cordis, and tool catalogs. + * Enumerable facts come from source; hybrid graphs add manifests for policy the + * source cannot infer, while curated graphs explain flow and ownership. + * `--check` verifies the generated set. */ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' @@ -606,10 +609,8 @@ function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.Sourc } const target = expr.expression.getText(sf) if (target === 'ctx' || target === 'this.ctx') return true - // Scoped-dispatch spellings (the agent-scoping seam): the loop's fused dispatcher (`events` - // from `agentEvents(ctx, agent)`), an agent's setup context (`childCtx`), the agent's own - // context handle (`this.loopCtx`), and the session store's captured dispatch context - // (`emitCtx`). + // Scoped-dispatch spellings are conventional names. Keep this list in sync + // with renames or the relationship matrix can silently lose an edge. return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx' } @@ -656,9 +657,8 @@ function renderEventRelations(pkgs: Pkg[]): string { const relation = relations.get(event.name) ?? { dispatchers: new Map>(), listeners: new Set() } lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`) } - // Completeness guard: every DECLARED event must have at least one dispatcher edge — a - // zero-dispatcher row is either dead vocabulary or (the observed failure mode) a dispatch - // spelling the AST scan does not recognize, silently dropping the producer from the matrix. + // Every declared event needs a dispatcher: zero means dead vocabulary or an + // unrecognized dispatch spelling. Listener-free extension points remain valid. const undispatched = [...events] .filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0) .map(event => event.name) @@ -785,7 +785,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit policy stays on `fs/*` events. Generic pre/post waterfalls host hook and approval policy, `ctx.approval` resolves asks before guards, and `tools/execute` hosts around-dispatch concerns such as timeouts. `tools/result` observes the immutable final outcome. Code Mode sends both `run_code` and its serialized sub-calls through this pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`, while `tools/result` observes the immutable outcome after transforms, lossless-JSON validation, and outer error normalization. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContext` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-module-graph.ts b/scripts/gen-module-graph.ts index ea59c173d8..5361af39c8 100644 --- a/scripts/gen-module-graph.ts +++ b/scripts/gen-module-graph.ts @@ -1,5 +1,7 @@ /** - * Generate (and verify) the module dependency graph in docs/module-graph.md. + * Generate `docs/module-graph.md` from in-repo `peerDependencies`, the canonical + * runtime edges. The deterministic output groups packages by directory and + * renders both Mermaid and a dependency table; `--check` verifies freshness. */ import { dirname, resolve } from 'node:path' @@ -161,8 +163,8 @@ if (process.argv.includes('--check')) { try { committed = readFileSync(resolve(root, OUT), 'utf8') } catch { - // Only an ENOENT (file not yet generated) is expected here; readFileSync of a - // present-but-unreadable file is not a state this repo produces. + // A missing artifact is the expected read failure. Any read failure has the + // same remedy here—regenerate—so it is reported as stale below. committed = null } if (committed === content) { diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index e614ddd90a..293c7c75dc 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -1,8 +1,9 @@ /** * Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and - * the owning `SurfaceEventType` union. Event declarations must be unique, - * explicitly typed, documented, and free of Cordis-only `@mode` tags. `--check` - * verifies the committed artifact. + * the owning `SurfaceEventType` union. This is the durable-record vocabulary, + * not the live Cordis bus. Event declarations must be unique, explicitly typed, + * documented, inheritance-free, and free of Cordis-only `@mode` tags; every + * surface-union member must resolve to one. `--check` verifies the artifact. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -81,7 +82,10 @@ function rawJsDoc(text: string, node: ts.Node): string { return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : '' } -/** Parse pre-tag JSDoc prose into one-line paragraphs and bullets for the catalog. */ +/** + * Parse pre-tag JSDoc prose into one-line paragraphs and bullets, unwrap + * `{@link ...}`, and report whether the forbidden `@mode` tag appears. + */ function parseJsDoc(raw: string): { doc: string; hasMode: boolean } { const inner = raw .replace(/^\/\*\*/, '') @@ -188,7 +192,10 @@ function packageNameFor(rel: string, scanRoot: string): string | null { } } -/** Collect and validate every `SessionEventMap` declaration merge. */ +/** + * Collect every `SessionEventMap` merge, rejecting inherited, non-literal, + * untyped, undocumented, duplicate, or incorrectly owned members in one report. + */ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { const entries: LogEventEntry[] = [] const violations: string[] = [] @@ -202,8 +209,9 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { for (const { decl, topLevel } of sessionEventMapDecls(sf)) { const declSrc = pointer(rel, sf, decl) if (topLevel) { - // The top-level form is the OWNING vocabulary, and it has exactly one home: the single - // EXPORTED declaration in the owning package. + // The top-level form has one home: the single exported declaration in + // the owning package. Same-named interfaces elsewhere are different + // types and must not enter the on-disk catalog. const pkg = packageNameFor(rel, scanRoot) if (pkg !== SESSION_MODULE) { violations.push(`top-level interface SessionEventMap (${declSrc}) is outside ${SESSION_MODULE} (package ${pkg ?? 'unknown'}). Rename the interface, or contribute events via declare module '${SESSION_MODULE}'.`) diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 8beecabae9..ab6b01c10d 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -2,7 +2,8 @@ * Generate `docs/tool-catalog.md` from schemas collected by booting each tool * plugin. Runtime registration is the source of truth for computed schemas; * the manifest is checked against every on-disk `tool-*` package. `--check` - * verifies the committed artifact. + * verifies the committed artifact. Rationale and ownership live in + * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' @@ -35,7 +36,11 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -/** Tool package plus the non-default dependencies needed to boot it. */ +/** + * Tool package plus its hand-maintained boot recipe. The caller mounts the + * prompt and registry; each recipe supplies only package-specific seams and + * config, while `dir` participates in the completeness check. + */ interface ToolPackage { /** The npm package name, used as the catalog section heading. */ pkg: string @@ -134,7 +139,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'], async mount(ctx) { - // The tool injects `fs`; boot the local backend to satisfy it. + // The tool needs `fs`; the bare provider is sufficient because policy + // changes behavior, not schema shape. await ctx.plugin(LocalFileSystem) await ctx.plugin(ToolFs) }, @@ -207,8 +213,8 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - // The tools inject `web`; boot the seam plus one search and one fetch provider so both - // `web_search` and `web_fetch` register. + // Mount search and fetch providers so both tools register. Their schemas + // do not depend on provider identity or availability. await ctx.plugin(WebService) await ctx.plugin(WebSearchExa) await ctx.plugin(WebFetchLocal) diff --git a/scripts/jsdoc.ts b/scripts/jsdoc.ts index 8311450195..3b8250a89b 100644 --- a/scripts/jsdoc.ts +++ b/scripts/jsdoc.ts @@ -3,7 +3,8 @@ * catalog generator (`scripts/gen-cordis-catalog.ts` — the events + `ctx.` service * surface), the plugin config catalog generator (`scripts/gen-config-catalog.ts`, which * renders the parsed prose), and the export-surface gate (`scripts/verify-export-jsdoc.ts` — - * every module-level export). + * every module-level export). This is the single definition of description, + * parameter, return, and stale-tag completeness across those surfaces. */ import ts from 'typescript' @@ -25,8 +26,9 @@ export function rawJsDoc(text: string, node: ts.Node): string { export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial' /** - * Parse a raw JSDoc block into description prose + the `@mode` tag (when present). - * + * Parse a raw JSDoc block into description prose and an optional `@mode`. Prose + * ends at the first block tag, paragraphs collapse to one line, bullet items + * remain separate lines, and `{@link X}` renders as `X`. * @param raw - the raw comment text including the JSDoc delimiters. * @returns the collapsed description prose plus the parsed `@mode` (or null). */ @@ -80,9 +82,8 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { } /** - * Parse the block tags of a raw JSDoc comment for the completeness checks: every `@param name - * — description` entry plus the `@returns` description. - * + * Parse `@param` and `@returns` descriptions, including continuation lines. + * Parameter separators are optional and `[optional]` names unwrap. * @param raw - the raw comment text including the JSDoc delimiters. * @returns the `@param` name→description map plus the `@returns` description * (null when the tag is absent, '' when present but empty). @@ -119,13 +120,15 @@ export function parseTags(raw: string): { params: Map; returns: } /** - * Check that required parameter tags exist and no stale tag remains. + * Require a non-empty tag for each non-exempt identifier parameter, reject + * binding-pattern parameters, and reject stale tags. Exempt parameters may + * still be documented. * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`. - * @param surface - surface noun used in diagnostics. + * @param surface - surface noun used in binding-pattern diagnostics. * @param parameters - the declaration's parameter list. * @param tags - the parsed `@param` name→description map from parseTags. * @param sf - source file used to render binding patterns. - * @param isExempt - parameters that need no tag. + * @param isExempt - parameters whose tag is optional, such as `this` or waterfall `next`. * @param violations - the aggregate list violations append to. */ export function checkParams( @@ -157,8 +160,8 @@ export function checkParams( /** * Check the `@returns` half of the completeness contract: a non-`void` / `Promise` * return needs a non-empty `@returns`, and the return type must be ANNOTATED — a pure-AST - * walk cannot classify an inferred return. - * + * walk cannot classify an inferred return. Void returns may still carry an + * optional tag, for example to document resolution timing. * @param where - the offender label violations open with. * @param typeNode - the declared return type annotation, or undefined when inferred. * @param returns - the parsed `@returns` description from parseTags (null when absent). diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 9ec83d622a..bce8c31dd4 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -7,7 +7,8 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' -// publint every harness package. +// Discover harness packages at packages//; group containers, +// examples, and private vendored sources are not package targets. const root = resolve(import.meta.dirname, '..') const packagesRoot = resolve(root, 'packages') diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index d3a0b4e3d5..2d2b5ac84c 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -2,7 +2,8 @@ * Shared source of truth for the RFC index: the tree walker (structure rules) and the README * table renderer. `gen-rfc-index.ts` writes the generated regions; * `verify-rfc-classification.ts` checks structure and asserts the committed regions are fresh. - * Pure module — no side effects on import. + * Lifecycle and class sets are closed under `docs/rfc/README.md`; rows derive + * from path, H1, and filename date and sort deterministically. Import is pure. */ import { readFileSync, readdirSync } from 'node:fs' diff --git a/scripts/verify-doc-budgets.ts b/scripts/verify-doc-budgets.ts index 7c7e6dc57d..854d8d105f 100644 --- a/scripts/verify-doc-budgets.ts +++ b/scripts/verify-doc-budgets.ts @@ -1,7 +1,9 @@ /** * Enforce `wc -w`-style ceilings from `scripts/doc-budgets.manifest.json`. * Missing files and invalid ceilings fail; `--list` reports current usage. - * Ceiling changes remain reviewable manifest edits. + * Only listed standing docs are budgeted. Ceilings ratchet down with at least + * 5% headroom; raising one requires the justification defined in + * `docs/AGENTS.md`. */ import { existsSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index bcbc93c7f8..0f0d02b1fd 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -1,6 +1,7 @@ /** * Verify root-relative `docs/*.md` tokens in repo-authored TypeScript. The - * textual scan requires the extension and excludes built and vendored source. + * textual scan requires the extension, checks matching string literals too, + * and excludes built declarations and vendored source. */ import { existsSync, globSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-export-jsdoc.ts b/scripts/verify-export-jsdoc.ts index 83b8509e39..44729e73f0 100644 --- a/scripts/verify-export-jsdoc.ts +++ b/scripts/verify-export-jsdoc.ts @@ -1,9 +1,10 @@ /** * Enforce JSDoc on every non-vendored package export. Functions and public * class methods require parameter and non-void return documentation; exported - * declarations require description prose. Framework protocol slots, - * constructors, inherited members, augmentations, and source re-exports keep - * their documentation at the declaring contract. Unknown export forms fail. + * declarations require description prose. Inline callable types, overload + * signatures, namespace members, and public class members are included; + * framework slots, constructors, inherited contracts, augmentations, and source + * re-exports keep their docs at the declaring contract. Unknown forms fail closed. */ import { existsSync, globSync } from 'node:fs' diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 6ff14e93df..4a3e96f10e 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -1,7 +1,8 @@ /** * Verify that relative Markdown links, images, and definitions resolve. URL, * root-absolute, and in-page targets are excluded; query strings and fragments - * do not affect the filesystem check. Symlinked instruction files are deduped. + * do not affect resolution against the source file. The checker never rewrites, + * and symlinked instruction files are deduped. */ import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs' diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index 9d9333ff61..6eafaebace 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -1,7 +1,8 @@ /** * Reject Markdown prose paragraphs spanning multiple physical lines. The GFM - * AST distinguishes paragraphs from multiline structural nodes; symlinked - * instruction files are deduped. + * AST distinguishes paragraphs—including those in lists and blockquotes—from + * multiline structural nodes. The checker never rewrites; symlinked instruction + * files are deduped. The owning convention is in `docs/AGENTS.md`. */ import { globSync, readFileSync, realpathSync } from 'node:fs' diff --git a/scripts/verify-mermaid.ts b/scripts/verify-mermaid.ts index 2f9331e6d6..c487c2fd5c 100644 --- a/scripts/verify-mermaid.ts +++ b/scripts/verify-mermaid.ts @@ -1,6 +1,7 @@ /** - * Parse every repo-authored Mermaid fence with Mermaid itself. Scope matches the - * Markdown link gate. Run with `tsx scripts/verify-mermaid.ts`. + * Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence + * checks cannot. Scope intentionally matches the Markdown link gate, including standing docs, + * package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`. */ import { globSync, readFileSync, realpathSync } from 'node:fs' diff --git a/scripts/verify-package-paths.ts b/scripts/verify-package-paths.ts index 36e7f0e1b3..eeb922f32d 100644 --- a/scripts/verify-package-paths.ts +++ b/scripts/verify-package-paths.ts @@ -83,9 +83,8 @@ function findViolations(absPath: string): Violation[] { // class may have swallowed (`packages/core/tools.` / `…/tools/`). const ref = m[0].replace(/[./]+$/, '') if (existsSync(resolve(root, ref))) continue - // A reference INTO a package's built `lib/` is a build OUTPUT, not an authored-source - // location: it does not exist until `pnpm run build` emits it, and CI runs this gate - // before the build step. + // Skip unbuilt `lib/` only below a real depth-two package root. A stale + // group-less path still fails; `lib` is not a blanket escape hatch. const parts = ref.split('/') const libAt = parts.indexOf('lib') if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 9762b11340..0511efd2e7 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,7 +1,8 @@ /** * Enforce RFC lifecycle/class paths, dated filenames, and titles; verify the * generated index and reject index rows in the curated README. Structural rules - * and rendering are shared with `rfc-index.ts`. + * and rendering are shared with `rfc-index.ts`; the closed classification + * contract lives in `docs/rfc/README.md`. */ import { readFileSync } from 'node:fs' diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts index 123a6bdd30..341f184abb 100644 --- a/scripts/verify-rfc-format.ts +++ b/scripts/verify-rfc-format.ts @@ -1,7 +1,8 @@ /** * Enforce RFC headers, lifecycle-specific sections, alternatives, and retired * marker rules. Classification and filenames belong to the sibling tree gate; - * translation structure belongs to the pairing gate. + * translation structure belongs to the pairing gate. Exact format and + * grandfathering rules live in `docs/rfc/README.md`. */ import { readFileSync } from 'node:fs' diff --git a/scripts/verify-scoped-dispatch.ts b/scripts/verify-scoped-dispatch.ts index 1cbc4e3f81..64e2333340 100644 --- a/scripts/verify-scoped-dispatch.ts +++ b/scripts/verify-scoped-dispatch.ts @@ -4,6 +4,7 @@ * `packages/support/invariants/src/index.ts`, which enforces carriers at dispatch time) and * the event declarations' JSDoc (the "Scope-filtered dispatch" sentence rendered into the * events catalog, which tells plugin authors what a scoped listener will and won't hear). + * Registry-subject notifications are intentionally unfiltered and belong in neither set. */ import { globSync, readFileSync } from 'node:fs' diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 7fd5b64ada..6cf52bd1ac 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -2,7 +2,9 @@ * Enforce complete English/Chinese pairs, matching structure, and recorded git * blob hashes under the bilingual manifest. `--list` reports state; `--write` * records both sides after human review. Translation quality remains a review - * responsibility. + * responsibility. A complete pair includes both documents and its sidecar; + * headings, fences, tables, lists, and link targets must align, while excluded + * documents may have neither counterpart nor sidecar. See `docs/i18n/README.md`. */ import { createHash } from 'node:crypto' diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 9a4dd75fba..729fd632cd 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -10,7 +10,7 @@ import ts from 'typescript' const root = resolve(import.meta.dirname, '..') -/** Markdown scope shared with doc-typecheck. */ +/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */ const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md'] /** One manifest entry: a documented type-equiv block and its source symbol. */ @@ -34,7 +34,11 @@ interface EquivBlock { code: string } -/** Remove comments and normalize whitespace for structural comparison. */ +/** + * Remove comments and normalize whitespace so prose-only edits do not drift + * structural copies. This is intentionally not a general tokenizer: repo type + * declarations do not contain comment delimiters inside string literals. + */ function normalize(code: string): string { return code .replace(/\/\*[\s\S]*?\*\//g, '') diff --git a/tsdown.config.ts b/tsdown.config.ts index e239188a7d..c403e1253b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,7 +11,8 @@ import { defineConfig } from 'tsdown' */ export default defineConfig({ // Explicit globs: `workspace: true` would also discover examples (any package.json), but only - // vendor and the packages hierarchy are pnpm workspaces. + // vendor and the packages hierarchy are pnpm workspaces. Landlock platform packages contain only + // a prebuilt native binary, so they have no JavaScript entry to bundle. workspace: ['vendor/*', 'packages/*/*'], entry: ['lib/types/index.js'], outDir: 'lib', diff --git a/vitest.config.ts b/vitest.config.ts index b4bfbbbef3..30af2d3612 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ // Native path resolution reads each package's nearest tsconfig, but only the root defines - // workspace paths. Keep this plugin pinned to the root map. + // workspace paths. Keep this plugin pinned to the root map so unbuilt bare package imports resolve + // to source; native resolution would fall through to absent `lib/` outputs. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], @@ -13,7 +14,8 @@ export default defineConfig({ // executable code; vendor/ and examples/ are out of scope (examples are // exercised by the demo smoke test instead). include: ['packages/*/*/src/**/*.ts'], - // Self-executing bins and workers are covered by subprocess tests outside v8 collection. + // Types-only files have no runtime coverage. Importing self-executing bins/workers would boot + // them inside the unit process, so real subprocess/Worker tests cover their thin entry glue. exclude: ['packages/*/*/src/types.ts', 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts'], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 28540dc605..adc8b3f3b2 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -1,7 +1,9 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Real-API end-to-end tests: `pnpm run test:e2e`, file pattern *.e2e.ts. +// Real-API suite, separate because it spends tokens. Each test self-skips without +// DEEPSEEK_API_KEY for keyless CI; the credentialed workflow preflights the secret. Values may come +// from the environment or gitignored root `.env`, with optional DEEPSEEK_BASE_URL. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 34c7de7742..903cd3e129 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -1,7 +1,10 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Snapshot tests: `pnpm run test:snapshot`, file pattern *.snapshot.ts. +// Replay is the keyless default: boot the real ACP subprocess from recorded model scripts and diff +// normalized transcript plus persisted-log goldens. `record` calls the real API and updates fixtures +// and goldens; `refresh` replays committed scripts and updates only current goldens. Replay/refresh +// never load `.env`; only record reads a key from the environment or gitignored root `.env`. if (process.env.DSH_SNAPSHOT === 'record') { try { process.loadEnvFile(new URL('.env', import.meta.url).pathname)