Merge refreshed docs/i18n-batch-cds-postmortem into docs/i18n-batch-rfc

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md
#	.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md
#	.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md
#	.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
#	.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.zh.md
#	.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md
#	.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.zh.md
#	.agents/notes/implemented/architecture/2026-06-18-session-surface.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md
#	.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md
#	.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
#	.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-extract-example-app-packages.zh.md
#	.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md
#	.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md
#	.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md
#	.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md
#	.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md
#	.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md
#	.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md
#	.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.zh.md
#	.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md
#	.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md
#	.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-03-filesystem-directory-listing-seam.zh.md
#	.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
#	.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md
#	.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md
#	.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md
#	.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
#	.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md
#	.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md
#	.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md
#	.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md
#	.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md
#	.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md
#	.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.zh.md
#	.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md
#	.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md
#	.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md
#	.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.zh.md
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-interception-seams.zh.md
#	.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md
#	.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.i18n.yaml
#	.agents/notes/implemented/feature/2026-06-30-subagent-observe-enrich.zh.md
#	.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md
#	.agents/notes/implemented/feature/2026-07-05-skill-system.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-05-skill-system.zh.md
#	.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
#	.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md
#	.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
#	.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md
#	.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md
#	.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md
#	.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md
#	.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md
#	.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md
#	.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.i18n.yaml
#	.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.zh.md
#	.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml
#	.agents/notes/implemented/process/2026-06-11-quality-gates.md
#	.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md
#	.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml
#	.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md
#	.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.i18n.yaml
#	.agents/notes/implemented/process/2026-06-11-vendor-cordis-as-source.zh.md
#	.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml
#	.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md
#	.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml
#	.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md
#	.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml
#	.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md
#	.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.i18n.yaml
#	.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.zh.md
#	.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.i18n.yaml
#	.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.zh.md
#	.agents/notes/implemented/process/2026-06-20-rfc-classification.i18n.yaml
#	.agents/notes/implemented/process/2026-06-20-rfc-classification.zh.md
#	.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.i18n.yaml
#	.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.zh.md
#	.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.i18n.yaml
#	.agents/notes/implemented/process/2026-07-03-documentation-graph-atlas.zh.md
#	.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml
#	.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md
#	.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml
#	.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md
#	.agents/notes/implemented/process/2026-07-04-generate-rfc-index-tables.i18n.yaml
#	.agents/notes/implemented/process/2026-07-04-generate-rfc-index-tables.zh.md
#	.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml
#	.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md
#	.agents/notes/implemented/process/2026-07-05-uniform-rfc-format.i18n.yaml
#	.agents/notes/implemented/process/2026-07-05-uniform-rfc-format.zh.md
#	.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.i18n.yaml
#	.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.zh.md
#	.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml
#	.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md
#	.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
#	.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
#	.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml
#	.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md
#	.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml
#	.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md
#	.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.i18n.yaml
#	.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.zh.md
#	.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.i18n.yaml
#	.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md
#	.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-19-drop-mutable-session-summary.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-collapse-trace-only-session-events.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.md
#	.agents/notes/implemented/simplification/2026-06-20-prune-dead-seam-methods.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md
#	.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md
#	.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.i18n.yaml
#	.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.zh.md
#	.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md
#	.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-share-app-bin-boot-glue.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.zh.md
#	.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.zh.md
#	.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.zh.md
#	.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.zh.md
#	.agents/notes/implemented/testing/2026-06-11-property-based-testing.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md
#	.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md
#	.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md
#	.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.zh.md
#	.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.zh.md
#	.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-22-fork-snapshot-scenarios.zh.md
#	.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.i18n.yaml
#	.agents/notes/implemented/testing/2026-06-22-subagent-snapshot-replay.zh.md
#	.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.i18n.yaml
#	.agents/notes/implemented/testing/2026-07-04-hook-snapshot-matrix.zh.md
#	.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.i18n.yaml
#	.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.zh.md
#	.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml
#	.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md
#	.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml
#	.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md
#	.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.i18n.yaml
#	.agents/notes/proposed/architecture/2026-06-16-typed-event-schemas.zh.md
#	.agents/notes/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml
#	.agents/notes/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md
#	.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.i18n.yaml
#	.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md
#	.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.i18n.yaml
#	.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.zh.md
#	.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.i18n.yaml
#	.agents/notes/proposed/feature/2026-07-08-interactive-side-sessions.zh.md
#	.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.i18n.yaml
#	.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.zh.md
#	.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.i18n.yaml
#	.agents/notes/proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.zh.md
#	.agents/notes/proposed/process/2026-06-11-api-extractor-reports.i18n.yaml
#	.agents/notes/proposed/process/2026-06-11-api-extractor-reports.md
#	.agents/notes/proposed/process/2026-06-11-api-extractor-reports.zh.md
#	.agents/notes/proposed/process/2026-06-11-architectural-conformance.i18n.yaml
#	.agents/notes/proposed/process/2026-06-11-architectural-conformance.zh.md
#	.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.i18n.yaml
#	.agents/notes/proposed/process/2026-06-11-supply-chain-and-vendor-drift.zh.md
#	.agents/notes/proposed/process/2026-06-20-discover-package-inventory.i18n.yaml
#	.agents/notes/proposed/process/2026-06-20-discover-package-inventory.zh.md
#	.agents/notes/proposed/simplification/2026-06-20-unify-agent-and-session-id.i18n.yaml
#	.agents/notes/proposed/simplification/2026-06-20-unify-agent-and-session-id.zh.md
#	.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.i18n.yaml
#	.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.zh.md
#	.agents/notes/proposed/simplification/2026-07-12-simplify-session-log-representation.i18n.yaml
#	.agents/notes/proposed/simplification/2026-07-12-simplify-session-log-representation.zh.md
#	.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.i18n.yaml
#	.agents/notes/proposed/testing/2026-06-11-deterministic-and-stress-testing.zh.md
#	.agents/notes/proposed/testing/2026-06-11-mutation-testing.i18n.yaml
#	.agents/notes/proposed/testing/2026-06-11-mutation-testing.zh.md
#	.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.i18n.yaml
#	.agents/notes/rejected/architecture/2026-06-11-immutable-public-surfaces.zh.md
#	.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.i18n.yaml
#	.agents/notes/rejected/architecture/2026-06-20-providerless-example-base.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-assembled-assistant-messages-only.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-drop-acp-session-load.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-drop-bash-output-spill-files.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-drop-durable-step-boundaries.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-drop-unused-session-lineage.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-fold-session-persistence-interface.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-retire-mid-turn-steering.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-single-session-acp-bridge.zh.md
#	.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.i18n.yaml
#	.agents/notes/rejected/simplification/2026-06-20-truncate-interrupted-turns.zh.md
#	.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.i18n.yaml
#	.agents/notes/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.zh.md
#	.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml
#	.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md
#	.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.i18n.yaml
#	.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.zh.md
#	docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
#	docs/rfc/implemented/architecture/2026-06-18-session-surface.md
#	docs/rfc/implemented/architecture/2026-06-20-branded-ids.md
#	docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md
#	docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
#	docs/rfc/implemented/feature/2026-07-07-session-prefix.md
#	docs/rfc/implemented/process/2026-06-20-rfc-classification.md
#	docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md
#	docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md
#	docs/rfc/implemented/process/2026-07-06-parallel-github-ci-gates.md
#	docs/rfc/implemented/process/2026-07-06-parallel-pre-push-gates.md
#	docs/rfc/implemented/process/2026-07-12-package-model-experience-contract.md
#	docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md
#	docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md
#	docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md
#	docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md
#	docs/rfc/proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md
#	docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md
#	docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md
#	docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md
#	scripts/translation-pairing.manifest.json
This commit is contained in:
Tianyi Cui
2026-07-22 23:07:14 +08:00
2944 changed files with 205120 additions and 33445 deletions

3
.agents/notes/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# AGENTS.md — Agent Notes
Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md).

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668
README.zh.md: b98d54ca64ed6150ddfc9f25c98ac64fe9a344f0

113
.agents/notes/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Agent Notes
English | [中文](README.zh.md)
One kind of design doc lives here. An **Agent Note** records a decision or proposal that shapes this codebase — the *why* and *what we gave up*, the parts code and docs can't carry. This file is the front door and contract: where Agent Notes live, when to write one, and [the in-file format](#the-file-format).
## Layout and naming
Every Agent Note has two axes, both encoded in its **path**`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`:
- **Lifecycle** (the top-level folder) is the Agent Note's status, and an Agent Note moves between folders as that status changes:
- **`proposed/`** — proposals reviewed before implementation; not yet built (or only partly).
- **`implemented/`** — the decision shipped. The file records what was decided and what was rejected, and is **kept current with what actually shipped**: when the code later moves a file, renames a package, or changes a key/default, the Agent Note is updated in the same change to match (facts only — paths, names, structure — not the decision itself). See [implemented/AGENTS.md](implemented/AGENTS.md).
- **`rejected/`** — the proposal was considered and declined. Kept for the record so the rejection isn't re-litigated.
- **Class** (the nested folder) is the *kind* of decision — see [Classification](#classification) below.
The date in the filename is when the topic was **first proposed** (per git history). Cross-references between Agent Notes use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders.
The tree is the inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale.
## Classification
Each Agent Note belongs to one path-encoded class from the closed set in `scripts/agent-note-tree.ts`; the classification gate rejects other folders. Adding a class requires updating the canonical set and this section. See the [classification Agent Note](implemented/process/2026-06-20-agent-note-classification.md).
| Class | What it covers |
|---|---|
| `feature` | A new user- or model-facing capability. |
| `bug-fix` | Corrects a defect or closes a gap a postmortem surfaced. |
| `simplification` | Removes code, behavior, or surface area without adding a capability. |
| `architecture` | A structural decision about the **shipped source** — how packages relate, what the runtime vocabulary is. |
| `process` | Tooling, policy, or workflow **around** the code — gates, the package manager, vendoring — not runtime behavior. |
| `testing` | Test infrastructure and strategy. |
The `architecture` / `process` line: **architecture** is about the source we ship; **process** is the surrounding tooling and workflow. (`refactor` is deliberately absent — it overlaps `simplification`, whose discriminator, "does observable behavior change?", already covers it.)
## When to write one
Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)).
Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md).
## The file format
Every Agent Note follows one in-file format, enforced by `pnpm run verify-agent-note-format` ([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts), part of `doc-sync`); the rationale for the format — and the alternatives it rejected — is [the uniform-format Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md).
### The header block
The first three lines of every Agent Note are exactly:
```markdown
# Agent Note: <title>
Status: <status>
```
followed by a blank line. The `Status:` value is one of three forms, and must agree with the lifecycle folder the file sits in — the gate cross-checks them:
- `Status: proposed`
- `Status: implemented`
- `Status: rejected — <why, in one line>`
The status carries no dates and no parentheticals: the filename holds the first-proposed date, git holds everything else, and an "accepted in amended form" note is body content (state the amendment where the decision is stated). The rejection reason is the one status with content, because a rejected Agent Note's verdict is the fact readers come for.
### The body skeleton
Every Agent Note opens its body with `## Problem` — the motivation, written to stand without the solution. What follows depends on the lifecycle; recurring sections use these canonical names and nothing else, while genuinely bespoke technical sections (package topology, wire contracts, schemas) remain free-form between the required ones.
#### `proposed/`
```markdown
## Problem
## Proposal
…bespoke sections…
## Alternatives considered
## Acceptance criteria
## Risks
```
`## Proposal` is the intended change and may legitimately speak in the future tense — plans, migration steps, and open questions belong here while the work is unbuilt. `## Acceptance criteria` says what observable state means done. `## Risks` covers both what could go wrong and what the change knowingly gives up.
#### `implemented/`
```markdown
## Problem
## Decision
…bespoke sections…
## Alternatives considered
## Consequences
```
`## Decision` describes shipped reality in the present tense, and the whole file is kept current with it per [implemented/AGENTS.md](implemented/AGENTS.md). `## Consequences` records what the trade-off cost **and** bought. Proposal-era headings are spec-speak here and the gate rejects them: `## Proposal`, `## Plan`, `## Migration plan`, and `## Acceptance criteria` may not appear in an implemented Agent Note (the [slop checklist](../../docs/AGENTS.md) names why). A `## Testing`, `## Deferred`, or `## Related` section is fine where it states present-tense fact.
#### `rejected/`
A rejected Agent Note is the proposal, frozen: it keeps whatever proposal-time sections it had (including `## Acceptance criteria` or `## Plan`), and the verdict lives on the `Status:` line. Only the header block, the `## Problem` opener, a `## Proposal` section, and the Alternatives-considered mandate below apply.
### Alternatives considered — mandatory
Every Agent Note carries an `## Alternatives considered` section: each genuine alternative and why it lost, one bold-led paragraph per alternative or a `### Why not <X>?` subsection per contested one. A decision recorded without what it beat invites re-litigation — the failure Agent Notes exist to prevent.
Alternatives are recorded, never invented. An Agent Note dated before 2026-07-05 whose alternatives are not reconstructible from the record carries this exact comment in place of the section, which the gate accepts for pre-format files only:
```markdown
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
```
### Moving between lifecycles
Moving a file between lifecycle folders means updating the `Status:` line and re-satisfying that folder's skeleton in the same change — the gate fails the move otherwise. Concretely, `proposed/``implemented/` rewrites `## Proposal` into a present-tense `## Decision`, folds `## Acceptance criteria` and `## Risks` into `## Consequences` (or a present-tense `## Testing`/`## Verification` section for what now pins the behavior), and drops plans in favor of what shipped — the rewrite [implemented/AGENTS.md](implemented/AGENTS.md) requires, made mechanical. `proposed/``rejected/` only adds the reason to the `Status:` line and freezes the file.
### Chinese counterparts
A `.zh.md` counterpart mirrors its English sibling's structure section-for-section under the [i18n contract](../../docs/i18n/README.md); the machine-checked header tokens (`# Agent Note: ` and the `Status:` line) stay in English verbatim. The format gate skips `.zh.md` files — the pairing gate owns their consistency.

117
.agents/notes/README.zh.md Normal file
View File

@@ -0,0 +1,117 @@
# Agent Notes
[English](README.md) | 中文
这里存放一类设计文档。**Agent Noteagent 决策记录)** 记录塑造本代码库的决策或提案:代码和文档无法承载的*为什么*以及*放弃了什么*。本文件是入口和契约Agent Note 存放在哪里、何时需要写一份,以及[文件内格式](#the-file-format)。
## 布局与命名
每份 Agent Note 有两个维度,都编码在其**路径**中:`{lifecycle}/{class}/yyyy-mm-dd-topic-title.md`
- **生命周期**(顶层文件夹)是 Agent Note 的状态Agent Note 随状态变化在文件夹之间移动:
- **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。
- **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**当代码后续移动文件、重命名包package或更改键名/默认值时Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。
- **`rejected/`**:提案经过讨论后被否决。保留以备查阅,避免同一问题被反复争论。
- **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。
文件名中的日期是该主题**首次提出**的时间(以 git 历史为准。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。
目录树就是清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。
<a id="classification"></a>
## 分类
每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。
| 类别 | 覆盖范围 |
|---|---|
| `feature` | 面向用户或模型的新功能。 |
| `bug-fix` | 修正缺陷或弥补事故复盘postmortem发现的缺口。 |
| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 |
| `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 |
| `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 |
| `testing` | 测试基础设施与策略。 |
`architecture``process` 的界线:**architecture** 关乎我们交付的源码;**process** 关乎围绕源码的工具与工作流。(`refactor` 被有意排除:它与 `simplification` 重叠,而后者的判别标准「可观察行为是否改变」已经覆盖了它。)
## 何时需要写一份
每个非平凡变更都必须在同一 PR 中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式或者其他维护者可能合理重新审视的决策就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。
更新已经拥有该决策的 Agent Note 即可满足规则不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。
<a id="the-file-format"></a>
## 文件格式
每份 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`[scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts)`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。
### 头部块
每份 Agent Note 的前三行严格为:
```markdown
# Agent Note: <title>
Status: <status>
```
后跟一个空行。`Status:` 的值有三种形式,且必须与文件所在的生命周期文件夹一致——门禁会交叉检查:
- `Status: proposed`
- `Status: implemented`
- `Status: rejected — <why, in one line>`
状态行不带日期、不带括号补充说明文件名记录首次提出日期git 记录其余一切;「以修订形式接受」之类的说明属于正文内容(在陈述决策的地方说明修订)。拒绝原因是唯一带内容的状态,因为读者查阅被否决的 Agent Note 时,结论正是他们要找的。
### 正文骨架
每份 Agent Note 的正文以 `## Problem` 开头动机写法上不依赖解决方案即可独立成文。后续内容取决于生命周期固定章节使用以下规范名称且仅限这些名称而真正独特的技术章节包拓扑、协议契约、schema 等)在必需章节之间可自由组织。
#### `proposed/`
```markdown
## Problem
## Proposal
…bespoke sections…
## Alternatives considered
## Acceptance criteria
## Risks
```
`## Proposal` 描述拟议的变更,可以合理地使用将来时态——计划、迁移步骤和待解决问题在工作尚未完成时属于此处。`## Acceptance criteria` 说明什么可观察状态意味着完成。`## Risks` 涵盖可能出错的事项以及该变更有意放弃的东西。
#### `implemented/`
```markdown
## Problem
## Decision
…bespoke sections…
## Alternatives considered
## Consequences
```
`## Decision` 以现在时态描述已交付的现实,整个文件按 [implemented/AGENTS.md](implemented/AGENTS.md) 的要求与之保持同步。`## Consequences` 记录权衡的代价**与**收益。提案阶段的标题在此属于规格用语,门禁会拒绝它们:`## Proposal``## Plan``## Migration plan``## Acceptance criteria` 不得出现在 implemented Agent Note 中(原因见 [slop 检查清单](../../docs/AGENTS.md))。`## Testing``## Deferred``## Related` 章节在陈述现在时态的事实时是允许的。
#### `rejected/`
被否决的 Agent Note 是冻结的提案:保留提案时的所有章节(包括 `## Acceptance criteria``## Plan`),结论写在 `Status:` 行上。仅头部块、`## Problem` 开头、`## Proposal` 章节以及下方的「曾考虑的替代方案」强制要求适用。
### 曾考虑的替代方案——必需
每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not <X>?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。
替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note在该章节位置放置以下精确注释门禁仅对格式规范之前的文件接受此注释
```markdown
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
```
### 在生命周期之间移动
将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/``implemented/``## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria``## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/``rejected/` 仅在 `Status:` 行添加原因并冻结文件。
### 中文对侧文件
`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ``Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。

View File

@@ -0,0 +1,11 @@
# AGENTS.md — Implemented Agent Notes
These Agent Notes describe shipped decisions. Follow the [root instructions](../../../AGENTS.md), [documentation standard](../../../docs/AGENTS.md), and [Agent Note format](../README.md#the-file-format); `verify-agent-note-format` gates the lifecycle-specific structure.
## Keep an implemented Agent Note current with what actually shipped
Keep paths, symbols, defaults, and mechanisms current in the same change that alters them. Rewrite stale facts in place; do not append change history.
### This is not a license to rewrite the *decision*
Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; see the [Agent Note contract](../README.md).

View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-content-block-vocabulary.md: 6a90813ec90c6522a88a09d3536ce0cef8250238
2026-06-11-content-block-vocabulary.zh.md: c9c65ae7e556cdf4eced0d45051862ff87fcd0fe

View File

@@ -0,0 +1,28 @@
# Agent Note: Provider-neutral content-block vocabulary owned by dsh-llm
Status: implemented
English | [中文](2026-06-11-content-block-vocabulary.zh.md)
## Problem
The harness needs one internal language for messages that the loop, session log, and all plugins speak.
## Decision
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`) and mid-turn steering (`steering/message`) originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). 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
- **Mirror the DeepSeek/OpenAI chat-completions shape** — zero mapping cost for the first provider, but awkward for rich content (reasoning, tool results as structured blocks).
- **Adopt Anthropic's Messages block structure verbatim** — battle-tested, but the canonical types would mirror a third-party API the harness does not target first.
## Consequences
- Reasoning has a core home without provider-specific shapes.
- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md).
- Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes.
- Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests.
- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost.

View File

@@ -0,0 +1,28 @@
# RFC: 由 dsh-llm 拥有的提供方无关内容块词汇
Status: implemented
[English](2026-06-11-content-block-vocabulary.md) | 中文
## 问题
harness 需要一套统一的内部消息语言,供 agent loop智能体循环、会话日志和所有插件共同使用。
## 决策
自主拥有词汇:消息是类型化内容块的数组(`text``reasoning``tool-call``tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource``FinishReason``TurnTrigger``TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式wire format——映射成本留在适配器中正是它该在的地方。
会话内上下文注入(`context/message``steering/message`)渲染为带标签的 user-role 信封system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
## 曾考虑的替代方案
- **镜像 DeepSeek/OpenAI chat-completions 结构**:对第一个提供方零映射成本,但对富内容(推理、结构化块形式的工具结果)处理不便。
- **原样采用 Anthropic Messages 块结构**:经过实战检验,但规范类型将镜像一个 harness 并非首要对接的第三方 API。
## 后果
- 推理reasoning在核心层有了归属无需依赖提供方特有的结构。
- 多模态块只有在适配器、UI 和上下文压缩context compaction三方协同支持后才会回归见 [drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md)。
- 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见 [producer-less variants](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md) 与 [inert request knobs](../simplification/2026-07-04-drop-inert-request-knobs.md) RFC。
- 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。
- 跨包package边界的 ID 使用品牌类型(`CallId``SessionId``AgentId`)——零运行时开销的名义类型。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-custom-schema-dsl.md: 34c018b779d45c5eadb7337cb060c43b6f8d09b1
2026-06-11-custom-schema-dsl.zh.md: 674a6b1a0b67617ffb4ab919899aa108c646a489

View File

@@ -0,0 +1,23 @@
# Agent Note: Custom typed tool-schema DSL instead of schemastery
Status: implemented
English | [中文](2026-06-11-custom-schema-dsl.zh.md)
## Problem
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
A small custom DSL in dsh-tools: `SchemaSpec` (per-property specs with `required: true` booleans), type-level `InferArgs<S>` mapping a spec to the argument type (required keys non-optional, others genuinely optional via `?`), a runtime `schemaSpecToJsonSchema()` converter, and `defineTool()` tying them together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` — that's how MCP-sourced tools arrive.
## Alternatives considered
**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly.
## Consequences
- 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 after an early optionality bug.

View File

@@ -0,0 +1,23 @@
# RFC: 使用自定义类型化 tool-schema DSL 替代 schemastery
Status: implemented
[English](2026-06-11-custom-schema-dsl.md) | 中文
## 问题
工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。
## 决策
在 dsh-tools 中实现一个小型自定义 DSL`SchemaSpec`(逐属性规格,带 `required: true` 布尔值);类型层面的 `InferArgs<S>` 将规格映射为参数类型required 键为必选,其余通过 `?` 标记为真正可选);运行时的 `schemaSpecToJsonSchema()` 转换器;以及将三者串联的 `defineTool()``ToolRegistry.register()` 仍然接受原始 JSON Schema 的 `ToolDefinition`——MCP 来源的工具正是以此方式注册。
## 曾考虑的替代方案
**Schemastery**(已作为 vendor 引入,用于插件 Config经评估后被否决它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*因此会增加间接层却无法干净地产出协议格式wire format
## 后果
- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。
- DSL 有意保持小巧string/number/boolean/object/array、enum、default、嵌套 properties/items。相对完整 JSON Schema 的缺口union、format、constraint在真实工具提出需求之前暂不补齐。
- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-dev-invariants-over-deep-readonly.md: 01a9e45fac77d2513924e78566a44a6059dd9d28
2026-06-11-dev-invariants-over-deep-readonly.zh.md: 8f8db9f5bb095b8fc9c600b741c16d630a6c8167

View File

@@ -0,0 +1,60 @@
# Agent Note: Source-owned session immutability and dev-mode invariants
Status: implemented
English | [中文](2026-06-11-dev-invariants-over-deep-readonly.zh.md)
## Problem
The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service seams. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules.
The session log is the durable source of truth for replay, request reconstruction, persistence, and user-visible history. Code outside the session package must be able to inspect that history without retaining a reference that can rewrite it later, and inputs accepted from callers must not remain connected to caller-owned mutable objects.
Immutability of individual values is only half of the contract. A log can contain perfectly immutable records whose sequence, turn/step nesting, tool-call pairing, scoped delivery, or reconstructed model request is wrong. Those rules relate multiple records or services and cannot be established by freezing one object.
TypeScript readonly types are not a sufficient runtime boundary. They disappear when the program runs, a cast can bypass them, and a recursive `DeepReadonly<T>` would spread through every log and message consumer even though some downstream request-processing APIs intentionally work with mutable values.
## Decision
Responsibility is split between an always-on storage boundary and optional development assertions.
### Session owns immutable history
`Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, `session/event` observers receive the same record, and `session.events` returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
### Derived requests remain detached
`deriveMessages()` projects logged surface events into detached, deep-frozen `Message` objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call.
### Package-owned invariant companions check relationships
`dsh-invariants` registers the configurable `ctx.invariants` service and contains no product checks. Every package publishes a `./invariant` ownership companion; `dsh-session`, `dsh-agent`, `dsh-scope`, and `dsh-agent-loop` currently add the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service ([package-owned invariant service](2026-07-19-package-owned-invariant-service.md)).
When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage.
## Alternatives considered
### Pervasive deep-readonly types
[The rejected immutable-public-surfaces proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) would apply a recursive readonly type across public log and message surfaces. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the `Session` boundary protects every caller without that type propagation.
### Development-only freezing
Freezing history only when an invariants plugin is installed would make the core guarantee composition-dependent. Code could pass development tests and still corrupt history in production or in a focused composition that omits the plugin. Storage immutability is therefore always on, while the more expensive relational checks remain opt-in development support.
### Clone only when deriving messages
Detaching `deriveMessages()` would protect the most common request path but leave other readers of `session.events`, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
## Consequences
- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
- `session.events` exposes stable immutable snapshots instead of the private growing array.
- Request-side mutation cannot reach stored history through derived messages.
- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
- `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.
- The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records.

View File

@@ -0,0 +1,60 @@
# RFC: 源端拥有的会话不可变性与开发模式不变式
Status: implemented
[English](2026-06-11-dev-invariants-over-deep-readonly.md) | 中文
## 问题
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务 seam 的事实之间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 TypeScript readonly 类型同时表达两者,既无法建立运行时边界,也无法描述关系规则。
会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包package外部的代码必须能检视历史但不能保留一个可在之后改写历史的引用从调用方接受的输入也不能继续连接到调用方拥有的可变对象。
单个值的不可变性只是契约的一半。一份日志可以包含完全不可变的记录,但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务,无法通过冻结单个对象来建立。
TypeScript readonly 类型不是充分的运行时边界。它们在程序运行时消失,类型转换可以绕过它们,而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方,尽管某些下游请求处理 API 有意使用可变值。
## 决策
职责在始终启用的存储边界与可选的开发断言之间分离。
### Session 拥有不可变历史
`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的确切分离记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回该拥有的冻结事件,`session/event` 观察者接收同一记录,`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。
此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
### 派生请求保持分离
`deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
### 不变式插件检查关系
`dsh-invariants` 是一个纯监听器的开发插件。它不冻结记录没有配置dispose资源释放仅移除其断言。它检查需要跟踪状态或观察另一个 seam 的规则,包括单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent智能体状态转换、主体正确的作用域分发以及循环构建的请求与从其会话日志前缀重建的请求之间的等价性。
当插件附加到已有或已播种的会话时,它回放不可变日志以重建跟踪状态。这使得在轮次中途热重载是安全的,同时不赋予插件对会话存储的所有权。
## 曾考虑的替代方案
### 全面的 deep-readonly 类型
[被否决的不可变公共表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公共日志和消息表面上应用递归 readonly 类型。这能提供编辑器反馈但无法提供运行时保证TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。
### 仅在开发模式冻结
仅当不变式插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用,而开销更大的关系检查则保持为可选的开发支持。
### 仅在派生消息时克隆
分离 `deriveMessages()` 能保护最常见的请求路径,但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
## 后果
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
- `session.events` 暴露稳定的不可变快照,而非私有的增长数组。
- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为dispose 或省略该插件不会削弱日志不可变性。
- `dsh-invariants` 没有 `Config` 表面,因为它没有可调节的行为。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-event-sourced-sessions.md: c1460b84c5928a53537a5f6a51f41c4d5b101a12
2026-06-11-event-sourced-sessions.zh.md: 938404524cd23a5a5b3d6d0b7fd5020df3d35a51

View File

@@ -0,0 +1,28 @@
# Agent Note: Event-sourced sessions with derived message history
Status: implemented
English | [中文](2026-06-11-event-sourced-sessions.zh.md)
## Problem
The MVP requires strict event-based tracing with fully replayable sessions (严格的基于事件的trace、logging系统session完全可回放).
## Decision
A `Session` is an append-only log of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`); raw stream chunks are logged for token-level replay fidelity while the assembled `assistant/message` event is authoritative for derivation. Replay/fork = seed a new session with an existing log.
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 the message tool dispatch actually used. Regression tests pin that ordering.
## Alternatives considered
**A mutable message array with events fired as notifications** — simpler, but state and log can diverge; with event-sourcing the log IS the state, so divergence is structurally impossible.
## Consequences
- Replay, trace, and telemetry are structurally guaranteed, not bolted on.
- Persistence stays a plugin concern; the in-memory store ships in dsh-session.
- The event vocabulary is merge-extensible (plugins add e.g. compaction events); [session persistence](2026-06-14-session-persistence.md) froze its shape once the log became durable.
- Derivation cost grows with log length — compaction (future plugin) is the intended mitigation, not log mutation.

View File

@@ -0,0 +1,28 @@
# RFC: 事件溯源的会话与派生消息历史
Status: implemented
[English](2026-06-11-event-sourced-sessions.md) | 中文
## 问题
MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严格的基于事件的 trace、logging 系统session 完全可回放)。
## 决策
`Session` 是一份仅追加的、类型化的 `SessionEvent` 日志是唯一的真源。LLM大语言模型消息历史从日志*派生*`deriveMessages()`);原始流分片被记录以保证 token 级别的回放保真度,而组装后的 `assistant/message` 事件才是派生的权威依据。回放/fork = 用已有日志初始化一个新会话。
追加操作是同步的(热路径从不阻塞于 I/O`session/event` 是同步通知;持久化插件在后台缓冲写入,并在每个轮次结束时触发的 `session/flush` 检查点处等待排空。
顺序契约agent loop智能体循环*先*追加到会话,再发出对应的 Cordis 事件;`agent/step-result` waterfall瀑布式事件`assistant/message` 追加之前运行,因此日志记录的是工具调度实际使用的消息。回归测试固定了这一顺序。
## 曾考虑的替代方案
**可变消息数组 + 事件仅作通知发出**:更简单,但状态与日志可能分歧;采用事件溯源后,日志本身即是状态,分歧在结构上不可能发生。
## 后果
- 回放、追踪与遥测在结构上得到保证,而非事后附加。
- 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。
- 事件词汇可通过合并扩展插件可添加如压缩compaction事件[会话持久化](2026-06-14-session-persistence.md)在日志变为持久后冻结了其形状。
- 派生成本随日志长度增长,压缩(未来插件)是预期的缓解手段,而非日志变更。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-microkernel-event-taxonomy.md: 47e363f949b506756d9b20ee7dc53ca81e3f5e4d
2026-06-11-microkernel-event-taxonomy.zh.md: 3b0aa54108bf8737e6fe341c7b0797a554d328ec

View File

@@ -0,0 +1,31 @@
# Agent Note: Microkernel — extension via Cordis event taxonomy, one concrete loop
Status: implemented
English | [中文](2026-06-11-microkernel-event-taxonomy.zh.md)
## Problem
The product principle is "everything is a plugin": hooks, /goal, /loop, dynamic workflows, compaction, sandboxing, permissions, UI, persistence, MCP, skills must all be writable as plugins without modifying the core.
## Decision
Pure Cordis event taxonomy. The loop's extension seams are typed events with deliberate dispatch modes:
- **waterfall** (around-middleware) where plugins transform, veto, recover, or wrap: `agent/prompt-submit`, `agent/request`, `agent/request-error`, `agent/step-result`, `agent/turn-continuation`, `tools/pre-execute`, `tools/execute`, `tools/post-execute`, `llm/stream`, `system-prompt/assemble`.
- **serial** (awaited in listener order; a bail value stops later listeners) for ordered checkpoints: every `agent/pre-step` and `agent/post-step` listener runs when all abstain, while the first stop returned from `agent/turn-stop` makes the terminal decision final.
- **parallel** (awaited fan-out) where every listener must get an independent chance: the `session/flush` durability checkpoint.
- **emit** (synchronous fire-and-forget) for notifications: turn/step boundaries, stream chunks, lifecycle, errors, and the contained immutable `tools/result` observation.
The event vocabulary lives in interface packages (dsh-agent declares the agent/* events); `@deepseek-ai/dsh-agent-loop` is the only concrete loop plugin and is itself swappable — nothing outside it may depend on it.
## Alternatives considered
**A purpose-built middleware stack (koa-compose style)** and **an explicit phase state machine plugins insert into** — both would re-implement dispatch, disposal, and reload semantics that Cordis's native event system already provides; as Cordis effects, listeners get HMR and disposal for free.
## Consequences
- Every MVP feature maps to a listener (the [feature → mechanism map](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map) is the proof obligation, kept current).
- HMR and disposal come free: listeners and registrations are Cordis effects.
- Waterfall semantics (call `next()` or short-circuit) are non-obvious and must be taught — documented in AGENTS.md and covered by composition tests.
- The loop must be defensive: plugin exceptions are contained at turn level, steering from any seam is never stranded (regression-tested).

View File

@@ -0,0 +1,31 @@
# RFC: 微内核——通过 Cordis 事件分类体系实现扩展,唯一具体循环
Status: implemented
[English](2026-06-11-microkernel-event-taxonomy.md) | 中文
## 问题
产品原则是「一切皆插件」:钩子、/goal、/loop、动态工作流、上下文压缩context compaction、沙箱、权限、UI、持久化、MCP、skill技能都必须能以插件形式编写无需修改核心。
## 决策
纯 Cordis 事件分类体系。agent loop智能体循环的扩展 seam 是带类型的事件,具有明确的分发模式:
- **waterfall瀑布式事件**around-middleware插件可变换、否决或包装`agent/prompt-submit``agent/request``agent/step-result``agent/turn-continuation``tools/pre-execute``tools/execute``tools/post-execute``llm/stream``system-prompt/assemble`
- **serial**(按监听器顺序依次 awaitbail 值会阻止后续监听器执行):用于有序检查点。所有 `agent/pre-step` 监听器在全部弃权时才继续运行,而 `agent/turn-stop` 返回的第一个 stop 值即为最终的终止决策。
- **parallel**await 扇出):每个监听器都必须获得独立执行的机会:`session/flush` 持久性检查点。
- **emit**(同步 fire-and-forget用于通知轮次/步骤边界、流分片、生命周期、错误,以及包含不可变 `tools/result` 观测的事件。
事件词汇定义在接口包中dsh-agent 声明 agent/* 事件);`@deepseek-ai/dsh-agent-loop` 是唯一的具体循环插件,且自身可替换——外部不得依赖它。
## 曾考虑的替代方案
**专用中间件栈koa-compose 风格)** 与**显式阶段状态机(插件向其中插入阶段)**:两者都需要重新实现 Cordis 原生事件系统已提供的分发、dispose资源释放与重载语义作为 Cordis effect监听器天然获得 HMR热模块替换与 dispose 能力。
## 后果
- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。
- HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。
- waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。
- 循环必须具备防御性:插件异常在轮次级别被隔离,任何 seam 发出的 steering中途引导永远不会被搁置有回归测试保障

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-runtime-arg-validation.md: 82d01263225ce719e80631d1890f0a6cc328119d
2026-06-11-runtime-arg-validation.zh.md: 4bd6720f7ec87fcef028ba94f445a5210f1b0d7a

View File

@@ -0,0 +1,24 @@
# Agent Note: Runtime arg validation at the model boundary
Status: implemented
English | [中文](2026-06-11-runtime-arg-validation.zh.md)
## Problem
`defineTool` ([the custom schema DSL](2026-06-11-custom-schema-dsl.md)) gives tool authors a typed `execute(args)` via the `InferArgs<S>` mapping. But that type is a compile-time claim about a value that arrives at runtime as model-generated JSON: nothing forced the model to honor the schema, so a malformed call — missing a required key, a string where a number was declared, an enum value outside the set — reached `execute` typed-in-name-only. The tool body then either crashed on the bad shape (a generic stack trace the model can't act on) or, worse, silently misbehaved. Meanwhile the converter already encodes the exact structure a validator would need to walk.
## Decision
`validateArgs(spec, args): string[]` interprets a `SchemaSpec` over a runtime value, returning human-readable violations (empty = valid), and is total (never throws). `defineTool` runs it before the typed body; on violations it throws `ToolArgsError` (`code: 'INVALID_ARGS'`, message listing the violations), which the registry's existing execute-waterfall catch turns into an `isError` result the model reads and self-corrects from.
The validator mirrors `schemaSpecToJsonSchema` semantics exactly — same structure walked, same rules: top level must be a non-array object; required keys come only from `required: true`; extra keys are allowed (no `additionalProperties: false`); `default` is not applied; an `object`/`array` prop without `properties`/`items` only type-checks; `enum` is membership. Raw-registered (MCP) tools are not touched — they validate their own input.
## Consequences
- The model gets actionable feedback on its own malformed calls instead of an opaque crash, closing the gap between `InferArgs`'s promise and runtime reality.
- The validator and `InferArgs` must stay in agreement; [a property test](../testing/2026-06-11-property-based-testing.md) generates args satisfying a spec and asserts they pass `validateArgs` (with targeted corruptions rejected), closing that drift risk mechanically.
- `ToolArgsError` is a plain `Error` with a `code` field for now; if a harness-wide error taxonomy lands it becomes a subclass without changing callers that read `.message`.
- Validation cost is negligible next to a model call.
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -0,0 +1,24 @@
# RFC: 模型边界处的运行时参数校验
Status: implemented
[English](2026-06-11-runtime-arg-validation.md) | 中文
## 问题
`defineTool`[自定义 schema DSL](2026-06-11-custom-schema-dsl.md))通过 `InferArgs<S>` 映射为工具作者提供了类型化的 `execute(args)`。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON没有任何机制强制模型遵守 schema因此畸形调用缺少必需键、声明为数字的位置传入字符串、枚举值超出集合会以「仅名义类型化」的状态到达 `execute`。工具函数体要么在错误形状上崩溃(产生模型无法据以自我修正的通用堆栈跟踪),要么更糟——静默地行为异常。与此同时,转换器已经编码了校验器遍历所需的完整结构。
## 决策
`validateArgs(spec, args): string[]` 对运行时值解释一个 `SchemaSpec`,返回可读的违规列表(空数组 = 合法),且是全函数(永不抛出异常)。`defineTool` 在调用类型化函数体之前运行它;存在违规时抛出 `ToolArgsError``code: 'INVALID_ARGS'`,消息中列出违规项),注册表既有的 execute-waterfall瀑布式事件catch 将其转为模型可读取并据以自我修正的 `isError` 结果。
校验器严格镜像 `schemaSpecToJsonSchema` 的语义——遍历相同结构、执行相同规则:顶层必须是非数组对象;必需键仅来自 `required: true`;允许额外键(不设 `additionalProperties: false`);不应用 `default`;没有 `properties`/`items``object`/`array` 属性仅做类型检查;`enum` 是成员资格检查。原始注册的MCP工具不受影响——它们自行校验输入。
## 后果
- 模型在自身畸形调用上获得可操作的反馈,而非不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。
- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时断言定向破坏的参数被拒绝),以机械方式封堵漂移风险。
- `ToolArgsError` 目前是带 `code` 字段的普通 `Error`;如果日后引入 harness 级别的错误分类体系,它将变为子类,但不影响读取 `.message` 的调用方。
- 校验开销相对于一次模型调用可忽略不计。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-structured-error-taxonomy.md: 5a5f75038b6124457d2d4d08cbe0bec80415a387
2026-06-11-structured-error-taxonomy.zh.md: d5a45a447ca00d48cb8366911a10f34c584d915c

View File

@@ -0,0 +1,26 @@
# Agent Note: Structured error taxonomy
Status: implemented
English | [中文](2026-06-11-structured-error-taxonomy.zh.md)
## Problem
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.
## 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.
- `LlmError` and `ToolArgsError` (dsh-tools) extend it, keeping their existing codes.
- `ToolExecutionResult` gains optional `error: { name, code }`, populated in the registry's catch when the thrown value is a `HarnessError`. The agent loop forwards it onto the `tool/result` session event (which gained the same optional field), so the structured failure survives into the log for retry/sandbox plugins and replay. The model-facing text block is unchanged.
- The loop's `toError` wraps a non-Error throw in a `HarnessError` (`code: 'UNKNOWN'`, original chained as `cause`) instead of a bare `Error`, so even a bad throw carries a routable code into the session `error` event (which already surfaced `code`).
## Consequences
- 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.
- Argument validation retains its existing code and behavior; package-owned diagnostic invariants carry their stable code independently so the invariant registry does not import a product package. The shared base adds cross-seam routing metadata without changing model-facing text.
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -0,0 +1,26 @@
# RFC: 结构化错误分类体系
Status: implemented
[English](2026-06-11-structured-error-taxonomy.md) | 中文
## 问题
故障跨越 seam 时只是裸字符串。工具错误被扁平化为一个文本块name、code 和 stack 全部丢失),导致未来的沙箱/重试插件无法区分 ENOENT 和 EACCES模型得到的反馈也不如本可以那样具有可操作性。非 Error 的 throw 退化更严重agent loop智能体循环将其包装为 `new Error(String(x))`,丢弃了所有 code。而 `LlmError` 是系统中唯一的类型化错误,没有共享基类,消费方无法对其进行通用的 `instanceof` 判断。
## 决策
`dsh-llm`(叶子包,所有其他包都已依赖它,不引入新的依赖边)中引入一个 `HarnessError extends Error` 基类:稳定的 `code`(与 `message` 分离)、通过 `ErrorOptions` 进行 `cause` 链接、`name` 默认为子类名。`isHarnessError` 在 seam 处做类型收窄。
- `LlmError``ToolArgsError`dsh-tools`InvariantError`dsh-invariants现在继承该基类保留各自既有的 code。
- `ToolExecutionResult` 新增可选字段 `error: { name, code }`,在注册表的 catch 中当抛出值为 `HarnessError` 时填充。agent loop 将其转发到 `tool/result` 会话事件(该事件也新增了同一可选字段),使结构化的失败信息存活到日志中,供重试/沙箱插件和回放使用。面向模型的文本块保持不变。
- agent loop 的 `toError` 将非 Error 的 throw 包装为 `HarnessError``code: 'UNKNOWN'`,原始值作为 `cause` 链接),而非裸 `Error`;这样即使是不规范的 throw 也能携带可路由的 code 进入会话的 `error` 事件(该事件此前已暴露 `code`)。
## 后果
- 错误端到端可机器路由:插件可以基于 `error.code` 分支,而无需对 message 做子串匹配。
- 一个基类被广泛导入,但它位于所有包已经依赖的包中,代价仅是一条 import 语句,而非新的依赖边。
- `deriveMessages` 不会将 `error` 暴露到模型历史中——模型仍然看到文本块;结构化字段服务于代码和回放。
- 参数校验与开发不变式保留各自既有的 code 和行为;共享基类增加了跨 seam 的路由元数据,不改变面向模型的文本。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-11-tool-schemas-in-prompt-assembly.md: 260d56ffab054d874e13eb34eac029bf59b381fc
2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 5ae98dfac676574d00f34580ec079bb6a8f2331f

View File

@@ -0,0 +1,23 @@
# Agent Note: Tool schemas are part of the system-prompt assembly
Status: implemented
English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md)
## Problem
On the wire, tool schemas travel in a dedicated `tools` field of the model request, not in prompt text. Architecturally, though, "what the model is told it can do" is one coherent concern: prompt sections and the tool list are assembled from the same plugin contributions and consumed at the same moment.
## Decision
`PromptAssembly { sections, tools }`: the system-prompt service collects ordered text sections AND tool schemas (the tool registry auto-contributes a provider). The loop consumes one assembly per step; adapters map `sections` to the provider's system slot and `tools` to the wire `tools` field. The `system-prompt/assemble` waterfall is therefore a single interception point for everything the model is told up front — tool filtering (ToolSearch / progressive disclosure) is an assembly rewrite, same as prompt edits.
## Alternatives considered
**The loop queries the tool registry separately from the prompt service** — splits one coherent concern across two seams, and every interception that wants to shape "what the model is told" (tool filtering, plan mode) would need two listeners on two surfaces instead of one assembly rewrite.
## Consequences
- One waterfall governs the model's standing context; plugins like plan mode can swap prompt text and visible tools in one listener.
- The assembly interface is merge-extensible for future slots (no untyped `extras` bag — extension is declaration merging).
- Slight conceptual surprise (schemas in a "prompt" service) is documented here and in the package README.

View File

@@ -0,0 +1,23 @@
# RFC: 工具 schema 是系统提示词组装的一部分
Status: implemented
[English](2026-06-11-tool-schemas-in-prompt-assembly.md) | 中文
## 问题
在协议格式wire format层面工具 schema 通过模型请求中专用的 `tools` 字段传输,而非嵌入提示词文本。然而从架构角度看,「模型被告知它能做什么」是一个统一的关注点:提示词段落与工具列表由相同的插件贡献组装,并在同一时刻被消费。
## 决策
`PromptAssembly { sections, tools }`:系统提示词服务同时收集有序的文本段落和工具 schema工具注册表自动贡献一个提供方。agent loop智能体循环每个步骤消费一份 assembly适配器将 `sections` 映射到提供方的 system 槽位,将 `tools` 映射到协议格式的 `tools` 字段。因此 `system-prompt/assemble` waterfall瀑布式事件是模型预先获知的所有信息的唯一拦截点工具过滤ToolSearch / 渐进式披露)是一次 assembly 重写,与提示词编辑无异。
## 曾考虑的替代方案
**循环从工具注册表和提示词服务分别查询**:将一个统一的关注点拆到两个 seam 上任何想影响「模型被告知什么」的拦截工具过滤、plan 模式)都需要在两个接口上各挂一个监听器,而非一次 assembly 重写即可完成。
## 后果
- 一条 waterfall 统管模型的常驻上下文plan 模式等插件可以在一个监听器中同时替换提示词文本和可见工具。
- assembly 接口通过声明合并实现可扩展(没有无类型的 `extras` 包——扩展即声明合并),为未来的槽位预留空间。
- 将 schema 放在「提示词」服务中略有概念上的意外感,已在本文及 package README 中加以说明。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-13-capability-seams.md: f10182737347fc54ff3fd06edb299e569397773d
2026-06-13-capability-seams.zh.md: e65d2f31eb80ce80e92b3e4bbb4d5b8948185c44

View File

@@ -0,0 +1,32 @@
# Agent Note: Capability seams — interface / implementation / consumer split
Status: implemented
English | [中文](2026-06-13-capability-seams.zh.md)
## Problem
The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed.
This is distinct from "who provides vs. needs a capability at runtime", which Cordis already answers with services + `inject` (a provider registers `ctx.bash`; a consumer declares `inject: ['bash']` and its fiber pends until the service exists). That mechanism is necessary but doesn't dictate package boundaries; this Agent Note does.
## Decision
A swappable capability is **three packages**:
1. **Interface** — an abstract service + the vocabulary types, owning the `ctx.<key>` and depending only on its vocabulary dependencies (e.g. `dsh-bash`: `BashExecutor`, `BashRunResult`, `BashProcess`).
2. **Implementation** — a concrete subclass loaded as a plugin (e.g. `dsh-bash-local`: subprocesses, process-group kills, spill-file truncation). Sandboxed/remote backends are sibling packages implementing the same interface.
3. **Consumer** — what the model and plugins see (e.g. `dsh-tool-bash`: the `bash` schema, with background handles registered into the generic task runtime). Consumers `inject` the interface key and never import implementation types.
Implementation and consumer then evolve independently: a sandboxed executor replaces `dsh-bash-local` without touching a tool schema.
The split is not mandatory when the parts are genuinely one concern: the LLM seam folds interface + consumer into `dsh-llm` (the consumer is the loop itself, not a swappable schema surface) with adapters as the implementation packages. Don't split preemptively — a capability with one conceivable implementation and one consumer stays one package until a second appears.
## Alternatives considered
- **One combined package** — rejected because it recouples the three rates of change the split exists to separate (the whole point).
- **`@cordisjs/plugin-capability`** — a different axis entirely: it is a permission/capability-*security* service (named permissions with inheritance, tested against a session via `ctx.capability.test`), a candidate for the deferred permissions/sandbox work on the `tools/pre-execute` deny/ask seam, NOT a mechanism for swapping implementations. Confusing the two ("capability") is the trap this Agent Note names.
## Consequences
More packages and more boilerplate per capability (a `package.json`/`tsconfig`/README trio, the inject wiring). Bought: implementations and consumers ship and version independently, and a new backend never risks the model-facing contract. The rule is documented in [AGENTS.md](../../../../AGENTS.md) § Conventions ("Capability seams are three packages") and [architecture.md](../../../../docs/architecture.md) § "Capability seams"; the bash trio is the reference template. When to fold vs. split is a judgment call the architecture doc spells out — this Agent Note records *why* the default is to split.

View File

@@ -0,0 +1,32 @@
# RFC: 能力 seam——接口实现消费方三分
Status: implemented
[English](2026-06-13-capability-seams.md) | 中文
## 问题
harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*契约*(这项能力是什么)、*实现*(它如何运行)、*消费方接口*模型和其他插件面向什么编程。将三者捆绑在一个包package中会耦合这些变化速率——把本地执行器换成沙箱化执行器时模型看到的工具 schema 也会被搅动,尽管面向模型的契约从未改变。
这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过 service + `inject` 解决(提供方注册 `ctx.bash`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 RFC 决定的是包的边界。
## 决策
一项可替换的能力由**三个包**构成:
1. **接口**——一个抽象服务加词汇类型,拥有 `ctx.<key>`,仅依赖 cordis例如 `dsh-bash``BashExecutor``BashRunResult``BashTask`)。
2. **实现**——一个具体子类,以插件形式加载(例如 `dsh-bash-local`:子进程、进程组 kill、溢出文件截断。沙箱化远程后端是实现同一接口的兄弟包。
3. **消费方**——模型和插件看到的内容(例如 `dsh-tool-bash``bash`/`bash_output`/`bash_kill` 工具 schema。消费方 `inject` 接口键,从不导入实现类型。
实现与消费方由此独立演进:沙箱化执行器替换 `dsh-bash-local` 时无需触碰任何工具 schema。
当各部分确实属于同一个关注点时三分并非强制LLM大语言模型 seam 将接口 + 消费方合并为 `dsh-llm`(消费方是 agent loop智能体循环本身而非可替换的 schema 表面),适配器作为实现包。不要预防性地拆分——如果一项能力只有一种可设想的实现和一个消费方,就保持为一个包,直到第二种出现。
## 曾考虑的替代方案
- **单一合并包**:否决。因为它重新耦合了三分设计本要分离的三种变化速率(这正是拆分的意义所在)。
- **`@cordisjs/plugin-capability`**:这是完全不同的维度。它是一个权限/能力*安全*服务(具名权限加继承,通过 `ctx.capability.test` 对会话进行检测),是延后的权限/沙箱工作(`tools/pre-execute` deny/ask seam的候选方案不是替换实现的机制。混淆这两个「能力」概念正是本 RFC 所指出的陷阱。
## 后果
每项能力需要更多包和更多样板代码(一组 `package.json`/`tsconfig`/README加上 inject 接线)。换来的是:实现与消费方独立发布和版本管理,新后端永远不会波及面向模型的契约。该规则记录在 [AGENTS.md](../../../../AGENTS.md) § Conventions"Capability seams are three packages")和 [architecture.md](../../../architecture.md) § "Capability seams" 中bash 三件套是参考模板。何时合并、何时拆分是一个判断问题,架构文档对此有详细说明——本 RFC 记录的是*为什么*默认选择拆分。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-13-twin-llm-adapters.md: edd7080e2a16e9f1af2ebd56a4265040dc970c17
2026-06-13-twin-llm-adapters.zh.md: 2087d498f65326fa0a2cb74be409a926a5e343f5

View File

@@ -0,0 +1,27 @@
# Agent Note: Two LLM adapters as a design-verification twin
Status: implemented
English | [中文](2026-06-13-twin-llm-adapters.zh.md)
## Problem
`dsh-llm` owns a provider-neutral streaming vocabulary — the `StreamChunk` protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`) and the content-block types ([the content-block vocabulary](2026-06-11-content-block-vocabulary.md)). A vocabulary defined against a single adapter risks baking that adapter's quirks into the "neutral" contract: anything the one implementation happens to do becomes the de-facto spec, and the abstraction is unverified until a second provider arrives — by which point the leak is expensive to fix.
## Decision
Ship **two** adapters against the one contract from the start, deliberately built on different internals:
- `dsh-llm-deepseek` — hand-rolled `fetch` + SSE parsing against the DeepSeek API.
- `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary).
The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden.
## Alternatives considered
- **A single adapter** — less code and half the e2e cost, but leaves the "provider-neutral" claim unverified; the vocabulary would encode DeepSeek-via-fetch assumptions silently.
- **A mock second adapter** — cheaper but doesn't exercise a real provider's wire quirks, so it proves little. The twin is real-on-real.
## Consequences
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 Agent Note.

View File

@@ -0,0 +1,27 @@
# RFC: 以两个 LLM 适配器作为设计验证孪生体
Status: implemented
[English](2026-06-13-twin-llm-adapters.md) | 中文
## 问题
`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为烘焙进「中立」契约:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时泄漏已代价高昂。
## 决策
从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建:
- `dsh-llm-deepseek`:手写 `fetch` + SSEServer-Sent Events解析直接对接 DeepSeek API。
- `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。
二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts``StreamChunk` 上的约定usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。
## 曾考虑的替代方案
- **单一适配器**代码更少、e2e 成本减半,但「提供方无关」的声明无从验证;词汇会默默编码 DeepSeek-via-fetch 的假设。
- **mock 第二适配器**更便宜但不会触及真实提供方的协议格式wire format怪癖因此证明力有限。孪生体是真实对真实的验证。
## 后果
孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理reasoning模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey``baseURL``models`;手写适配器暴露 `thinking`/`reasoningEffort`pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 RFC 论证退役其中一个适配器。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-14-session-persistence.md: a50c076746981892239304266194d1e8e309574f
2026-06-14-session-persistence.zh.md: d609e663bd9e36f74bf34404c021a30f54d3bd76

View File

@@ -0,0 +1,36 @@
# Agent Note: Session persistence as an abstract service over the existing `SessionEvent`
Status: implemented
English | [中文](2026-06-14-session-persistence.zh.md)
## 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](../feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible.
The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append-only log the single source of truth and derives LLM history from it. Persistence had to stay faithful to that: persist the existing `SessionEvent` directly, with no parallel "persisted message" type that the log is converted to and from. The backend also had to be swappable — a file store now, a database store later — behind one interface.
## Decision
Persistence is an abstract **capability seam** ([capability seams](2026-06-13-capability-seams.md), the `dsh-bash` template), not loop or core logic:
1. **Interface** (`dsh-session-persistence`, `ctx.sessionPersistence`) — an abstract `SessionPersistence` service: `create`/`append`/`load`/`list`. Its persisted unit IS the existing `SessionEvent` (`{ type, seq, time, data }`), reused verbatim — no conversion type.
2. **Implementation** (`dsh-session-persistence-jsonl`) — an append-only logical JSONL log per session (a `SessionHeader` line then one `SessionEvent` per line, verbatim **including `assistant/chunk`**), encoded as [checksummed Zstandard frames by default](2026-07-19-zstandard-jsonl-session-logs.md) or raw lines by configuration.
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.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant 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 registers the fresh agent under the exact resumed id. 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.
## Alternatives considered
Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later.
## 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](../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.

View File

@@ -0,0 +1,36 @@
# RFC: 会话持久化作为基于现有 `SessionEvent` 的抽象服务
Status: implemented
[English](2026-06-14-session-persistence.md) | 中文
## 问题
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent智能体因此持久恢复"继续昨天的任务")、持久 fork 以及 ACPAgent Client Protocol`session/load` 方法([ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))都无法实现。
[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM大语言模型历史。持久化必须忠实于这一设计直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行"持久化消息"类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。
## 决策
持久化是一个抽象的**能力 seam**[能力 seam](2026-06-13-capability-seams.md)`dsh-bash` 模板),而非循环或核心逻辑:
1. **接口**`dsh-session-persistence``ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `create`/`append`/`load`/`list`。其持久化单元就是现有的 `SessionEvent``{ type, seq, time, data }`),原样复用,无转换类型。
2. **实现**`dsh-session-persistence-jsonl`):每个会话一个仅追加的 JSONL 日志(一行 `SessionHeader`,之后每行一个 `SessionEvent`,逐字节保留,**包括 `assistant/chunk`**)。
以下关键选择记录于此,因为它们是持久性的、有争议的、且出人意料的:
- **规范的持久日志逐字节保留每个 `SessionEvent`,包括 `assistant/chunk`。** `deriveMessages()` 跳过 chunk而过滤 chunk 的方案Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及加载验证 `events[i].seq === i` 要求日志是*连续*的;过滤掉 chunk 会留下空洞,同时破坏契约和恢复功能。基于 chunk 过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。
- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写到 `turn/end` 的事件永不被重写,且循环仅在轮次结束时刷写。由于一个被中断的轮次可能包含大量有效工作,`load` 保留其连续、可解析的事件,并为未应答的工具调用追加错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }``turn/end`。合成的结果保证恢复后的 provider transcript文本记录仍然有效。只有不完整的最后一条记录会被丢弃在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。
- **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)``append` 是 INSERT在一个断言连续 seq 契约的事务中),`load` 是 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类接口无变化opencode 在 SQLite/WAL 上运行的正是这个形状),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该契约以相同的语义约束两个后端(惰性物化、加载时关闭中断轮次、连续 seq一次表达在文件字节上一次表达在数据库行上。
- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会随 seed/fork 的会话免费携带,但元数据不是可回放状态,因此显式的日志外 header seam 是更干净的代价。header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。)
- **`ctx.agents.create()``ctx.agents.resume()` 是异步工厂resume 还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 等待 `ctx.sessionPersistence.load`,用加载的事件重建活跃会话(使 `lastTurnNumber`/`deriveMessages` 得以延续),并在恢复的 id 上启动一个新 agent不是 `${agentId}-session`。agent loop智能体循环不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 以明确的错误拒绝。
## 曾考虑的替代方案
上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 chunk 的规范日志**Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
格式版本控制header 携带一个 `version``load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。
## 后果
新增两个包package以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`[ACP 支持](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有的事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度`assistant/chunk` 保持逐字节不变。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-15-turn-enclosure-invariant.md: 26ae1890e481288b3be17f97db14c332f69be4b8
2026-06-15-turn-enclosure-invariant.zh.md: 1946f829100e39afed826b8cf3ba82d22058c27d

View File

@@ -0,0 +1,42 @@
# Agent Note: Every session event is enclosed in a turn
Status: implemented
English | [中文](2026-06-15-turn-enclosure-invariant.zh.md)
## Problem
A durable session-persistence backend (added in a companion change) uses the **turn** as its crash-recovery boundary: a crash can leave an unclosed final turn, which `load` closes with a synthetic `turn/end {kind:'interrupted'}` while preserving the turn's real events (see [session persistence](2026-06-14-session-persistence.md)). This recovery is only well-defined if nothing *legitimately* durable sits OUTSIDE a turn — between the last `turn/end` and the next `turn/start` — since such an event would be swept into the next turn's interrupted close.
That assumption did not hold. Two paths recorded events outside any turn:
1. **Queued user messages.** The loop drained queued messages and appended `user/message` *before* `turn/start` — so a turn's own prompt sat in the gap between the previous `turn/end` and the next `turn/start`.
2. **Idle context injection.** `agent.inject()` appends a `context/message` directly. Its real production caller is `dsh-tool-bash`, which injects a background-task completion notice from `ctx.bash.onTaskDone` — a callback that fires whenever a background bash task finishes, frequently while the agent is **idle** (between turns).
In case 2, if the injected `context/message` is the last event before a flush/dispose (no later turn appends a `turn/end`), `scanLog` treats it as crash debris and **drops it on resume** — the injected context is durably on disk but silently lost on reload. Case 1 was benign in isolation (a `user/message` is always followed by the turn it triggered) but made the "what may appear outside a turn" rule fuzzy.
## Decision
**Every session event lives inside a turn** — between a `turn/start` and its matching `turn/end`. Concretely:
- The loop appends queued `user/message` events **after** `turn/start` (inside the turn), not before it. `turn/end` is therefore owed the moment those messages are recorded, and the existing finalizer guarantees it.
- An `agent.inject()` made while the agent is **running** joins the already-open turn. While the current step executes assistant tool calls, accepted context waits in arrival order until that batch settles, then appends after every recorded result and before the turn closes even when execution is interrupted.
- An `agent.inject()` made while **idle** wraps its `context/message` in a one-shot turn: `turn/start{trigger:{kind:'injection'}}``context/message``turn/end{completed}`. A new `injection` variant joins the merge-extensible `TurnTriggerMap`.
- The loop derives the next turn number from the log each iteration (`lastTurnNumber(session) + 1`) instead of keeping a private counter, so an idle injection's one-shot turn cannot collide with the next real turn's number.
- The `dsh-session/invariant` companion registers the check with `ctx.invariants`: when selected, a `user/message` / `context/message` / `steering/message` appended while no turn is open throws an `InvariantError` attributed to `@deepseek-ai/dsh-session`.
The serializability invariant is enforced at the same source boundary (`Session.append` throws on non-JSON-serializable data), so "what may enter the log" is now governed in one place rather than discovered downstream by whichever backend happens to be watching.
## Alternatives considered
**Relax the reader instead of constraining the producer** — let `scanLog` commit events that sit outside an open turn. Rejected: a single, checkable producer-side rule beats a more permissive boundary scan that has to reason about partial turns *and* loose between-turn events.
## Consequences
The turn is now the *single* durability/replay boundary, so [session persistence](2026-06-14-session-persistence.md)'s crash-recovery rule is complete, not merely sufficient: an interrupted final turn is closed (with a synthetic `turn/end {interrupted}`) and its real events preserved, with zero risk of conflating between-turn context into it, because there is no between-turn context. `scanLog` stays simple (one possibly-open final turn, never a loose between-turn event), and an idle background-task notice survives persist + resume.
Costs: `agent.inject()` while idle now writes three log lines instead of one, and the derived history gains a turn that carries only injected context (no assistant output) — `deriveMessages()` already derives purely by event type, so this renders identically. The `injection` trigger is a new on-disk vocabulary value; like every `SessionEventMap`/`TurnTriggerMap` addition it is part of the frozen format. Event ordering within a turn changed (`turn/start` now precedes `user/message`), which is observable to anything that asserted the old order — the loop's own tests were the only such consumers.
The rule is intentionally producer-enforced and dev-checked rather than reader-tolerated: a future backend (SQLite/WAL) inherits the same clean boundary for free, and a plugin that records an event outside a turn fails loudly in dev instead of silently losing data on the next reload.
Failures detected during a turn are logged before `turn/end`. A later flush failure has no valid in-turn position, so it is reported through `agent/error` and logging rather than appended as a session event. This preserves a balanced replay log; durable operational diagnostics require a separate telemetry channel.

View File

@@ -0,0 +1,42 @@
# RFC: 每个会话事件都封闭在一个轮次内
Status: implemented
[English](2026-06-15-turn-enclosure-invariant.md) | 中文
## 问题
持久化的会话持久化后端(在配套变更中引入)以**轮次**作为崩溃恢复边界:崩溃可能留下一个未关闭的最终轮次,`load` 会用一个合成的 `turn/end {kind:'interrupted'}` 将其关闭,同时保留该轮次的真实事件(见[会话持久化](2026-06-14-session-persistence.md))。这种恢复只有在没有任何*合法的*持久事件位于轮次之外(即上一个 `turn/end` 与下一个 `turn/start` 之间的间隙)时才是良定义的,否则这类事件会被卷入下一个轮次的中断关闭中。
这一假设并不成立。有两条路径在任何轮次之外记录了事件:
1. **排队的用户消息。** agent loop智能体循环排空排队消息并在 `turn/start` *之前*追加 `user/message`——于是一个轮次自身的提示词落在了前一个 `turn/end` 与下一个 `turn/start` 之间的间隙中。
2. **空闲时的上下文注入。** `agent.inject()` 直接追加一条 `context/message`。它在生产环境中的真实调用方是 `dsh-tool-bash`,后者从 `ctx.bash.onTaskDone` 注入后台任务完成通知——该回调在后台 bash 任务完成时触发,而这经常发生在 agent **空闲**(轮次之间)时。
在情况 2 中,如果注入的 `context/message` 是 flush/dispose 之前的最后一个事件(之后没有轮次追加 `turn/end``scanLog` 会将其视为崩溃残留并在**恢复时丢弃**——注入的上下文已持久写入磁盘,但重新加载后被静默丢失。情况 1 本身无害(`user/message` 之后总会跟着它触发的轮次),但使「什么可以出现在轮次之外」这条规则变得模糊。
## 决策
**每个会话事件都位于一个轮次内部**:在 `turn/start` 与其匹配的 `turn/end` 之间。具体而言:
- agent loop 在 `turn/start` **之后**(轮次内部)追加排队的 `user/message` 事件,而非之前。因此,一旦这些消息被记录,就欠下一个 `turn/end`,既有的 finalizer 保证它被写入。
- agent **运行中**调用 `agent.inject()` 时,`context/message` 追加到已打开的轮次中(行为不变)。
- agent **空闲时**调用 `agent.inject()`,则将 `context/message` 包裹在一个一次性轮次中:`turn/start{trigger:{kind:'injection'}}``context/message``turn/end{completed}`。一个新的 `injection` 变体加入可合并扩展的 `TurnTriggerMap`
- agent loop 每次迭代从日志推导下一个轮次编号(`lastTurnNumber(session) + 1`),而不是维护一个私有计数器,这样空闲注入的一次性轮次不会与下一个真实轮次的编号冲突。
- `dsh-invariants` 插件在开发环境中**强制执行**该不变式:在没有打开轮次的情况下追加 `user/message` / `context/message` / `steering/message` 会抛出 `InvariantError`
可序列化性不变式在同一源码边界处强制执行(`Session.append` 对不可 JSON 序列化的数据抛出异常),因此「什么可以进入日志」现在由一个位置统一管控,而非由下游碰巧在监听的某个后端各自发现。
## 曾考虑的替代方案
**放宽读取端而非约束生产端**——让 `scanLog` 提交位于已打开轮次之外的事件。否决:一条单一、可检查的生产端规则优于一个更宽松的边界扫描(后者需要同时推理部分轮次*和*轮次间的散落事件)。
## 后果
轮次现在是*唯一的*持久性/回放边界,因此[会话持久化](2026-06-14-session-persistence.md)的崩溃恢复规则是完备的,而不仅仅是充分的:被中断的最终轮次被关闭(用合成的 `turn/end {interrupted}`),其真实事件得以保留,且零风险将轮次间上下文混入其中,因为不存在轮次间上下文。`scanLog` 保持简洁(最多一个可能未关闭的最终轮次,绝无散落的轮次间事件),空闲时的后台任务通知在持久化 + 恢复后依然存活。
代价:空闲时调用 `agent.inject()` 现在写入三行日志而非一行;派生的历史中多出一个仅包含注入上下文(无 assistant 输出)的轮次——`deriveMessages()` 已经纯粹按事件类型派生,因此渲染结果完全相同。`injection` 触发器是一个新的磁盘词汇值;与每次 `SessionEventMap`/`TurnTriggerMap` 的新增一样,它属于冻结格式的一部分。轮次内的事件顺序发生了变化(`turn/start` 现在先于 `user/message`这对任何断言旧顺序的代码可观测——agent loop 自身的测试是唯一的此类消费方。
该规则有意采用生产端强制、开发环境检查的方式而非读取端容忍的方式未来的后端SQLite/WAL无需额外工作即可继承同样干净的边界而在轮次外记录事件的插件会在开发环境中大声失败而非在下次重新加载时静默丢失数据。
轮次内检测到的失败在 `turn/end` 之前记录。后续的 flush 失败没有有效的轮次内位置,因此通过 `agent/error` 和日志报告,而非作为会话事件追加。这保持了回放日志的平衡;持久化的运维诊断需要一个独立的遥测通道。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-17-filesystem-capability-seam.md: c87c8a44d2e956a9613039378d26f72e8ee97ee7
2026-06-17-filesystem-capability-seam.zh.md: 4864e77ced5c078fc8c1e970a2ff88c78a37af2a

View File

@@ -0,0 +1,160 @@
# Agent Note: Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools
Status: implemented
English | [中文](2026-06-17-filesystem-capability-seam.zh.md)
## Problem
The harness has a concrete `bash` capability seam (`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`), but filesystem operations are about to be added as model-facing tools without an equivalent seam. If `read`, `write`, and `edit` directly use `node:fs`, the model-facing tool package will own filesystem execution policy, local path resolution, atomic write behavior, text decoding, symlink behavior, and edit semantics all at once.
That couples three concerns that change independently:
1. The filesystem contract: what operations plugins can ask for.
2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later.
3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting.
Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment.
We need the filesystem tools to land in the same capability-seam shape as bash before they become a public package surface.
## Decision
Filesystem access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-fs` (`packages/fs/fs`) owns the abstract `ctx.fs` service, the filesystem vocabulary types, and the `fs/*` policy event vocabulary.
2. `@deepseek-ai/dsh-fs-local` (`packages/fs/fs-local`) provides the first implementation, backed by the local filesystem.
3. `@deepseek-ai/dsh-tool-fs` (`packages/fs/tool-fs`) provides the model-facing `read`, `write`, and `edit` tools over `ctx.fs`, and is the executor that dispatches the `fs/*` events.
The consumer package depends only on the interface package, never on `dsh-fs-local`. A deployment that wants a different backend loads a different provider for `ctx.fs` without changing the tool schemas or model-facing prompt guidance.
The read-before-write/edit and observed-state policy is a fourth package, `@deepseek-ai/dsh-fs-policy` (`packages/fs/fs-policy`), contributed through the `fs/*` event gate rather than living on `ctx.fs`; a deployment loading `dsh-tool-fs` also loads `dsh-fs-policy` to get read-before-write/edit. This Agent Note established the three-package seam; the split of policy off the provider base class is decided by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md), and its realization as an event-gate plugin (not a method service) by [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md). This document is updated to describe that landed four-package shape.
The first backend is deliberately local-only: `dsh-fs-local` implements `ctx.fs` against the host filesystem. Future sibling backends can provide sandboxed, remote, virtual, or project-scoped filesystems behind the same interface.
The first consumer is deliberately text-file-only: `dsh-tool-fs` exposes model-facing `read`, `write`, and `edit` tools for UTF-8 text files. Future consumers can add directory listing, search/glob, binary-safe operations, file watching, or higher-level project operations without changing the local backend package, as long as the needed capability exists on `ctx.fs`. Direct directory listing was later added by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).
Filesystem permissions and sandboxing are not implied by this split. The local backend resolves relative paths from its configured base directory, but containment policy is a separate decision: either a stricter `ctx.fs` implementation enforces it, or a permission/sandbox plugin wraps `tools/execute` and vetoes calls before they reach the consumer.
Read-before-write/edit and observed state belong to `dsh-fs-policy`, not `ctx.fs`. Through the `fs/*` event gate, the policy records versions per opaque actor and supplies optional mutation expectations; the provider enforces freshness atomically. `dsh-tool-fs` emits the events without depending on the policy. See the [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [event-gate](2026-06-26-file-context-as-event-gate.md) Agent Notes.
## Package topology
The filesystem seam uses the same dependency direction as the bash trio:
```text
@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local
consumer interface implementation
```
`@deepseek-ai/dsh-fs` depends only on `cordis` plus the repo-wide `HarnessError` base from `@deepseek-ai/dsh-llm`. It declares the `ctx.fs` key, the abstract `FileSystem` service, the vocabulary types shared by backends and consumers, the filesystem error vocabulary, and the `fs/*` policy event vocabulary. It carries no observed-state store and no owner-derivation shape; the events pass an opaque `object` actor that the provider never reads, and the `dsh-fs-policy` plugin owns the owner-derivation shape and the observed-state store on top of those events.
`@deepseek-ai/dsh-fs-local` depends on `@deepseek-ai/dsh-fs` and `cordis`. It subclasses `FileSystem`, registers itself as `ctx.fs`, owns local-backend configuration such as the base directory, and contains all direct `node:fs` / `node:path` access. It holds no observed-state store — freshness is a version token the backend mints and the policy plugin records.
`@deepseek-ai/dsh-tool-fs` depends on `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `cordis`. It registers model-facing tools and prompt sections. It must not import `node:fs`, `node:path`, or `@deepseek-ai/dsh-fs-local`; filesystem execution always goes through `ctx.fs`. If the implementation needs concrete agent or session helper types, those dependencies belong in `tool-fs`; they must not leak back into `dsh-fs`.
The root `tool-fs` plugin registers the full filesystem tool suite (`read`, `write`, and `edit`) by composing the per-tool registration helpers. It injects `fs` and never imports an implementation package.
## `ctx.fs` contract
`@deepseek-ai/dsh-fs` owns a semantic filesystem service. It is higher-level than `readFile` / `writeFile` so `tool-fs` does not reimplement path resolution, versioning, text decoding, binary rejection, pagination, atomic replacement, symlink behavior, or literal edit semantics.
The interface covers these semantic operations:
- Resolve a model/plugin-supplied path into a backend-defined target.
- Stat target metadata without reading file contents.
- Read a bounded UTF-8 text page from a target.
- Create or replace a UTF-8 text file.
- Edit an existing UTF-8 text file by literal replacement.
The provider seam also carries the freshness hooks that policy builds on — but the observed-state store and owner derivation live in the `dsh-fs-policy` plugin, not on `ctx.fs`:
- The backend mints an opaque `version` token per target (in `stat` and in every read/mutation outcome).
- `writeText`/`editText` take an OPTIONAL version expectation: omit it for an unconditional bare-provider mutation, or supply it to guard the mutation inside the backend's atomic critical section.
- The `dsh-fs-policy` plugin decides that expectation on `fs/write-intent`/`fs/edit-intent` and records observed versions on `fs/observed`, keyed by an owner it derives from the opaque event actor (normally `exec.agent.session`).
Authorization is version freshness, not a full/partial view distinction: any read records the target's version, and a later write/edit is authorized as long as the file is still at that version — so a windowed read of lines 100-150 authorizes an edit of line 120. The observed-state store is a `WeakMap<owner, Map<targetKey, version>>` inside `dsh-fs-policy`; `dsh-fs` holds none of it and treats the actor as opaque. (This Agent Note first modeled a `FileState` cache with `full`/`partial` views on `ctx.fs`; the split-fs-seam and event-gate Agent Notes replaced that with the freshness-based policy plugin described here.)
Path resolution is explicit and allowed to be async. Local resolution may only normalize a path, but sandboxed/remote/project-scoped backends may need I/O to resolve a user-supplied path into a stable target identity.
Resolved targets must expose at least three concepts:
- The original input path, for diagnostics.
- An opaque `targetKey`, used for stale guards and file-state lookup. The local backend might use a realpath-like key; a remote backend might use a workspace URI or file id. Consumers must not parse or assume this is a local absolute path.
- A `displayPath`, used for model/UI-facing output. It may be a local absolute path, workspace-relative path, or remote URI depending on the backend.
Read and mutation results must include an opaque file `version`. The local backend derives its token from bigint stat metadata (`dev`, `ino`, `size`, `mtimeNs`, and `ctimeNs`) so same-size rewrites and inode replacement invalidate consumers reliably; a remote backend can use a revision id or hash-like token. The `dsh-fs-policy` plugin records versions for stale checks; consumers may display related metadata but must not interpret the version token.
The provider hands back decoded text: `readText` returns a whole regular text file, `streamText` streams the same text semantics for large files. Both own regular-file checks, bounded line/output handling is NOT theirs — line windowing, numbered-line rendering, and total-line accounting live in the executor (`dsh-tool-fs`), which reads through `ctx.fs` and renders the model-facing window. The provider owns UTF-8 decoding and binary/NUL rejection; it does not know about line windows or views.
Observed-state recording is not on `ctx.fs`: after a successful read the executor emits `fs/observed`, and the `dsh-fs-policy` plugin records `{ version }` for the deriving owner. There is no `full`/`partial` view — a read at any window records the version, and freshness (not view completeness) authorizes a later write/edit.
Full-file writes create or replace UTF-8 text files. Backends may create parent directories when that behavior is supported and documented. Existing non-regular targets are rejected. `writeText` takes an optional expectation: `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED` (the path the policy uses for an unobserved owner); `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`; omitting the expectation is the unconditional bare-provider create-or-overwrite. The policy plugin chooses which expectation to supply from the owner's observed state.
Literal edit is a provider primitive (`editText`), not composed in `tool-fs` from a read plus write. Literal matching, duplicate-match rejection, CRLF preservation, binary rejection, optional stale-version checking, and atomic read-modify-write must stay together inside the backend's mutation critical section. `editText` takes the same optional version expectation; the stale check runs before literal matching so an edit against an old read reports `FS_STALE_VERSION`. A remote backend may implement edit as a native compare-and-edit operation; the consumer does not force local-style composition.
The policy plugin, not `ctx.fs`, gates on prior observation: an `edit` requires a prior observation by the owner (else `FS_NOT_OBSERVED`), and the recorded version is passed to `editText` as the CAS basis. With the policy plugin absent, `ctx.fs` alone is a complete unconstrained seam (unconditional write/edit); the tool is never method-coupled to the policy.
Filesystem contract failures are thrown as `FsError extends HarnessError`, and the tool registry converts them into `isError` tool results with structured `{ name, code }` metadata. `dsh-fs` owns this vocabulary rather than each tool inventing messages. The codes are `FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_NOT_REGULAR_FILE`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, and `FS_ABORTED`. (An earlier draft included `FS_PARTIAL_OBSERVATION`; freshness-based authorization has no partial/full distinction, so it was dropped. Directory-listing-specific codes were added later by [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md).)
## Tool consumer behavior
`@deepseek-ai/dsh-tool-fs` is the model-facing consumer. It owns tool names, JSON schemas, argument validation at the model boundary, prompt sections, and result formatting. It does not own filesystem execution.
The first tool suite contains:
- `read`: inspect a UTF-8 text file and return line-numbered content with pagination guidance.
- `write`: create or fully replace a UTF-8 text file.
- `edit`: update an existing UTF-8 text file by replacing literal text, requiring a unique match by default and allowing an explicit replace-all mode.
Each tool follows the same execution shape:
1. Validate and normalize model arguments.
2. Call the appropriate `ctx.fs` operation.
3. Format the result as `ContentBlock[]` for the model.
4. Let thrown backend/tool errors flow through `ToolRegistry.execute()`, which converts them into `isError` tool results.
The package registers prompt guidance through `ctx.systemPrompt.section(...)` and registers schemas through `ctx.tools.register(...)`. Tool schemas still flow into the normal prompt assembly path via `SystemPrompt.assemble()` and `ToolRegistry.schemas()`; no agent-loop changes are required.
The tool package keeps model-facing contracts stable when backends change: a local backend and a remote backend may resolve paths differently internally, but the `read` / `write` / `edit` schemas do not change solely because the backend changes.
The default deployment requires a prior `read` before updating an existing file with `write` or `edit`. `tool-fs` does not implement this by checking whether a tool named `read` ran: it dispatches the `fs/write-intent`/`fs/edit-intent` events (passing the execution context as the opaque actor), and the `dsh-fs-policy` plugin derives the owner, gates on prior observation, and supplies the version expectation. Any windowed read authorizes a later write/edit as long as the file is unchanged. Creating a new file with `write` does not require prior observation.
The root plugin registers the full suite by composing the per-tool registration helpers. It injects `fs`, `tools`, and `systemPrompt`.
## Testing
Tests follow the package boundary, not only the user-visible tools: the service seam in `dsh-fs`; real filesystem behavior through the `ctx.fs` interface in `dsh-fs-local` (resolution, symlinks, streaming, binary/UTF-8 rejection, unconditional and version-guarded writes, literal-edit semantics, line-ending preservation, structured `FsError` codes); the consumer surface in `dsh-tool-fs` against the real local provider (mock only the model/clock, never the collaborator); and integration through `ctx.tools.execute()` with and without `dsh-fs-policy`, world-verified by reading files back from disk rather than trusting the returned `ContentBlock[]`. The observed-state/owner-derivation policy is tested in `dsh-fs-policy`, not here.
The defensive-pattern classes this repo has been bitten by are pinned directly:
- **Atomic-write temp-file safety.** Write/edit stage through a private random `0700` directory next to the target with an exclusive owner-only (`'wx'`, `0o600`) temp file, cleanup on failure, and a final atomic rename — mirroring the bash spill-file rules, because predictable world-readable temp paths invite symlink races and disclosure. Tests assert the permissions and that a pre-existing temp path is not clobbered; this primitive is a standing requirement of the seam.
- **`targetKey` identity through symlinks.** Two input paths resolving to the same realpath share one observed-state entry: a `read` via path A satisfies the read-before-edit guard for an `edit` via symlink path B, and a stale write through one path is detected through the other.
- **Concurrency / stale races.** Two concurrent write/edit operations against the same target settle deterministically — one succeeds, the other is rejected with `FS_STALE_VERSION` — and a successful edit refreshes recorded state so the same owner's next edit proceeds.
- **HMR safety and disposal.** Disposing the backend's fiber withdraws the `ctx.fs` provider; a later provider starts with no inherited state.
## Alternatives considered
- **Model-facing tools directly over `node:fs`** — the tool package would own execution policy, path resolution, atomic writes, text decoding, and edit semantics at once, coupling the three independently-changing concerns the Problem names and churning schemas on any backend swap.
- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same interface/implementation/consumer split as bash, and the combined name never became public surface.
- **Observed-state on `ctx.fs`** — the shape this Agent Note first landed; superseded by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md): a sandboxed/remote backend must not inherit model-facing observation policy, so the provider keeps only the version token and the optional version-guarded mutation.
## Consequences
**`cwd` can be mistaken for a sandbox.** The local backend's base directory is a resolution default, not automatically a containment boundary. If containment is required, it must be enforced by the backend contract or by a permission/sandbox plugin on `tools/execute`.
**The interface can become too local.** Returning fields such as `absolutePath` from `ctx.fs` would make remote, sandboxed, or virtual backends awkward. The contract should expose display metadata without requiring consumers to understand host paths.
**The interface can become too thin.** If `ctx.fs` only mirrors `node:fs` primitives, `tool-fs` will reimplement binary detection, pagination, atomic writes, and edit semantics. That recreates the coupling this Agent Note is trying to avoid.
**Edit semantics are race-prone by nature.** Literal edit is a read-modify-write operation; the guard is the backend's atomic mutation critical section plus the optional version expectation, so concurrent edits settle deterministically — one wins, the other gets `FS_STALE_VERSION`.
**Observed state does not belong on `ctx.fs`.** Recording what an execution context has seen is workflow policy, not raw filesystem I/O. This Agent Note first placed it inside the filesystem seam; the split-fs-seam Agent Note then established that a sandboxed/remote backend should not inherit model-facing observation policy, and moved it into the `dsh-fs-policy` plugin. The provider seam keeps only what write/edit safety genuinely needs at the storage layer — a backend-minted version token and an optional version-guarded mutation — while the policy plugin owns owner derivation, observed-state, and read-before-edit gating over the `fs/*` events.
**The `resolve`-then-operate shape costs an extra round-trip per call.** Each tool may resolve a path to an `FsTarget` and then issue the read/write/edit as a separate `ctx.fs` call. For the local backend this is negligible (resolution is in-memory path normalization), but a remote/sandboxed backend may turn each step into its own request, so a single `read` can become two network round-trips. Backends where the round-trip matters can cache or fold resolution internally while preserving the observable contract.
**Observed-state persistence is deferred.** Observed state lives in memory (the `WeakMap` inside `dsh-fs-policy`), so a resumed session conservatively requires files to be read again before write/edit until a future session-event or persistence mechanism makes observation replayable.
**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and stays limited to the error vocabulary.
**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive.

View File

@@ -0,0 +1,160 @@
# RFC: 文件系统能力 seam——ctx.fs、本地后端与面向模型的文件系统工具
Status: implemented
[English](2026-06-17-filesystem-capability-seam.md) | 中文
## 问题
harness 已有一个具体的 `bash` 能力 seam`dsh-bash` / `dsh-bash-local` / `dsh-tool-bash`),但文件系统操作即将作为面向模型的工具加入,却没有等价的 seam。如果 `read``write``edit` 直接使用 `node:fs`,面向模型的工具包将同时承担文件系统执行策略、本地路径解析、原子写入行为、文本解码、符号链接行为和编辑语义。
这把三个独立变化的关注点耦合在了一起:
1. 文件系统契约:插件可以请求哪些操作。
2. 后端:当前是本地磁盘,未来可能是沙箱/远程/项目作用域的文件系统。
3. 消费方接口:面向模型的 `read` / `write` / `edit` schema 与结果格式化。
如果没有 `ctx.fs` 接口,将本地文件系统访问替换为沙箱或远程后端时,即使面向模型的契约应当保持稳定,工具 schema、演示和提示词引导也会被迫变动。这还使权限/沙箱边界更难推理:一个 `cwd` 选项看起来像沙箱,但除非有显式的后端或 `tools/execute` 策略强制隔离,否则它只是一个基础路径。
我们需要文件系统工具在成为公开包package接口之前以与 bash 相同的能力 seam 形态落地。
## 决策
文件系统访问是一个一等的能力 seam遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md)
1. `@deepseek-ai/dsh-fs``packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。
2. `@deepseek-ai/dsh-fs-local``packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。
3. `@deepseek-ai/dsh-tool-fs``packages/fs/tool-fs`)通过 `ctx.fs` 提供面向模型的 `read``write``edit` 工具,是分发 `fs/*` 事件的执行器。
消费方包仅依赖接口包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。
读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-policy``packages/fs/fs-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-policy` 以获得读后写/编辑能力。本 RFC 确立了由三个包构成的 seam策略从提供方基类拆出的决策由 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 做出。本文已更新为描述最终落地的四包形态。
第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。
第一个消费方有意仅限文本文件:`dsh-tool-fs` 暴露面向模型的 `read``write``edit` 工具,处理 UTF-8 文本文件。未来的消费方可以添加目录列表、搜索/glob、二进制安全操作、文件监视或更高层的项目操作只要 `ctx.fs` 上存在所需能力,就无需改动本地后端包。直接目录列表后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。
文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但隔离策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。
读后写/编辑与观测状态属于 `dsh-fs-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见 [split-seam](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate](2026-06-26-file-context-as-event-gate.md) RFC。
## 包拓扑
文件系统 seam 使用与 bash 三件套相同的依赖方向:
```text
@deepseek-ai/dsh-tool-fs --depends on--> @deepseek-ai/dsh-fs <--depends on-- @deepseek-ai/dsh-fs-local
consumer interface implementation
```
`@deepseek-ai/dsh-fs` 仅依赖 `cordis` 加上来自 `@deepseek-ai/dsh-llm` 的仓库级 `HarnessError` 基类。它声明 `ctx.fs` 键、抽象 `FileSystem` 服务、后端和消费方共享的词汇类型、文件系统错误词汇,以及 `fs/*` 策略事件词汇。它不持有观测状态存储,也不持有 owner 推导形态;事件传递一个不透明的 `object` actor提供方从不读取它`dsh-fs-policy` 插件在这些事件之上拥有 owner 推导形态和观测状态存储。
`@deepseek-ai/dsh-fs-local` 依赖 `@deepseek-ai/dsh-fs``cordis`。它继承 `FileSystem`,将自身注册为 `ctx.fs`,拥有本地后端配置(如基目录),并包含所有直接的 `node:fs` / `node:path` 访问。它不持有观测状态存储——新鲜度是后端铸造、策略插件记录的版本令牌。
`@deepseek-ai/dsh-tool-fs` 依赖 `@deepseek-ai/dsh-fs``@deepseek-ai/dsh-tools``@deepseek-ai/dsh-system-prompt``cordis`。它注册面向模型的工具和提示词段落。它禁止导入 `node:fs``node:path``@deepseek-ai/dsh-fs-local`;文件系统执行始终通过 `ctx.fs`。如果实现需要具体的 agent 或会话辅助类型,这些依赖属于 `tool-fs`;它们禁止回漏到 `dsh-fs` 中。
`tool-fs` 插件通过组合各工具的注册辅助函数来注册完整的文件系统工具套件(`read``write``edit`)。它注入 `fs`,从不导入实现包。
## `ctx.fs` 契约
`@deepseek-ai/dsh-fs` 拥有一个语义文件系统服务。它比 `readFile` / `writeFile` 更高层,这样 `tool-fs` 就不必重新实现路径解析、版本管理、文本解码、二进制拒绝、分页、原子替换、符号链接行为或字面编辑语义。
该接口涵盖以下语义操作:
- 将模型/插件提供的路径解析为后端定义的目标。
- 获取目标元数据而不读取文件内容。
- 从目标读取有界的 UTF-8 文本页。
- 创建或替换一个 UTF-8 文本文件。
- 通过字面替换编辑一个已有的 UTF-8 文本文件。
提供方 seam 还携带策略所依赖的新鲜度钩子——但观测状态存储和 owner 推导位于 `dsh-fs-policy` 插件中,而非 `ctx.fs` 上:
- 后端为每个目标铸造一个不透明的 `version` 令牌(在 `stat` 以及每次读取/变更结果中)。
- `writeText`/`editText` 接受一个可选的版本期望:省略它表示无条件的裸提供方变更;提供它则在后端的原子临界区内守护变更。
- `dsh-fs-policy` 插件在 `fs/write-intent`/`fs/edit-intent` 上决定该期望,并在 `fs/observed` 上记录观测版本,以它从不透明事件 actor 推导出的 owner 为键(通常是 `exec.agent.session`)。
授权基于版本新鲜度,而非完整/部分视图的区分:任何读取都会记录目标的版本,后续的写入/编辑只要文件仍处于该版本就被授权——因此对第 100-150 行的窗口化读取可以授权对第 120 行的编辑。观测状态存储是 `dsh-fs-policy` 内部的 `WeakMap<owner, Map<targetKey, version>>``dsh-fs` 不持有任何此类数据,并将 actor 视为不透明。(本 RFC 最初建模了一个带 `full`/`partial` 视图的 `FileState` 缓存放在 `ctx.fs`split-fs-seam 和 event-gate RFC 将其替换为此处描述的基于新鲜度的策略插件。)
路径解析是显式的,允许异步。本地解析可能只做路径规范化,但沙箱/远程/项目作用域的后端可能需要 I/O 才能将用户提供的路径解析为稳定的目标标识。
解析后的目标必须至少暴露三个概念:
- 原始输入路径,用于诊断。
- 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。
- `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。
读取和变更结果必须包含不透明的文件 `version`。本地后端可以使用 mtime/size 或类似 hash 的令牌;远程后端可以使用 revision id。`dsh-fs-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。
提供方返回已解码的文本:`readText` 返回整个常规文本文件,`streamText` 为大文件流式传输相同的文本语义。两者负责常规文件检查;有界的行/输出处理不是它们的职责——行窗口化、带行号渲染和总行数统计位于执行器(`dsh-tool-fs`)中,执行器通过 `ctx.fs` 读取并渲染面向模型的窗口。提供方负责 UTF-8 解码和二进制/NUL 拒绝;它不知道行窗口或视图。
观测状态记录不在 `ctx.fs` 上:成功读取后,执行器发出 `fs/observed``dsh-fs-policy` 插件为推导出的 owner 记录 `{ version }`。没有 `full`/`partial` 视图——任何窗口的读取都记录版本,新鲜度(而非视图完整性)授权后续的写入/编辑。
全文件写入创建或替换 UTF-8 文本文件。后端在支持且有文档说明时可以创建父目录。已有的非常规目标被拒绝。`writeText` 接受一个可选期望:`createIfAbsent` 创建缺失的目标并拒绝已存在的(报 `FS_NOT_OBSERVED`,这是策略为未观测 owner 使用的路径);`replaceIfVersion` 仅在目标处于观测版本时替换,否则报 `FS_STALE_VERSION`;省略期望则为无条件的裸提供方创建或覆盖。策略插件根据 owner 的观测状态选择提供哪个期望。
字面编辑是提供方原语(`editText`),而非在 `tool-fs` 中由读取加写入组合而成。字面匹配、重复匹配拒绝、CRLF 保留、二进制拒绝、可选的陈旧版本检查和原子读-改-写必须一起留在后端的变更临界区内。`editText` 接受相同的可选版本期望;陈旧检查在字面匹配之前运行,因此基于旧读取的编辑会报 `FS_STALE_VERSION`。远程后端可以将编辑实现为原生的 compare-and-edit 操作;消费方不强制本地风格的组合。
策略插件(而非 `ctx.fs`)对先前观测进行门控:`edit` 要求 owner 有先前观测(否则报 `FS_NOT_OBSERVED`),记录的版本作为 CAS 基础传给 `editText`。在策略插件缺席时,`ctx.fs` 本身是一个完整的无约束 seam无条件写入/编辑);工具从不与策略方法耦合。
文件系统契约失败以 `FsError extends HarnessError` 抛出,工具注册表将其转换为带结构化 `{ name, code }` 元数据的 `isError` 工具结果。`dsh-fs` 拥有此词汇,而非由每个工具各自发明消息。错误码包括 `FS_NOT_FOUND``FS_NOT_TEXT``FS_STALE_VERSION``FS_NOT_OBSERVED``FS_NOT_REGULAR_FILE``FS_AMBIGUOUS_EDIT``FS_EDIT_NOT_FOUND``FS_ABORTED`。(早期草案包含 `FS_PARTIAL_OBSERVATION`;基于新鲜度的授权没有 partial/full 区分,因此已删除。目录列表相关的错误码后来由 [Add direct directory listing to the filesystem seam](2026-07-03-filesystem-directory-listing-seam.md) 添加。)
## 工具消费方行为
`@deepseek-ai/dsh-tool-fs` 是面向模型的消费方。它拥有工具名称、JSON Schema、模型边界的参数校验、提示词段落和结果格式化。它不拥有文件系统执行。
第一个工具套件包含:
- `read`:检查一个 UTF-8 文本文件并返回带行号的内容与分页引导。
- `write`:创建或完全替换一个 UTF-8 文本文件。
- `edit`:通过替换字面文本更新一个已有的 UTF-8 文本文件,默认要求唯一匹配,并允许显式的全部替换模式。
每个工具遵循相同的执行形态:
1. 校验并规范化模型参数。
2. 调用相应的 `ctx.fs` 操作。
3. 将结果格式化为面向模型的 `ContentBlock[]`
4. 让抛出的后端/工具错误流经 `ToolRegistry.execute()`,由其转换为 `isError` 工具结果。
该包通过 `ctx.systemPrompt.section(...)` 注册提示词引导,通过 `ctx.tools.register(...)` 注册 schema。工具 schema 仍通过 `SystemPrompt.assemble()``ToolRegistry.schemas()` 流入正常的提示词组装路径;无需改动 agent loop智能体循环
工具包在后端变化时保持面向模型的契约稳定:本地后端和远程后端内部可能以不同方式解析路径,但 `read` / `write` / `edit` schema 不会仅因后端变化而改变。
默认部署要求在用 `write``edit` 更新已有文件之前先 `read``tool-fs` 不通过检查是否运行过名为 `read` 的工具来实现这一点:它分发 `fs/write-intent`/`fs/edit-intent` 事件(将执行上下文作为不透明 actor 传递),`dsh-fs-policy` 插件推导 owner、对先前观测进行门控并提供版本期望。任何窗口化读取都能授权后续的写入/编辑,只要文件未变。用 `write` 创建新文件不要求先前观测。
根插件通过组合各工具的注册辅助函数来注册完整套件。它注入 `fs``tools``systemPrompt`
## 测试
测试遵循包边界,而不仅是用户可见的工具:`dsh-fs` 中的服务 seam`dsh-fs-local` 中通过 `ctx.fs` 接口测试的真实文件系统行为(解析、符号链接、流式传输、二进制/UTF-8 拒绝、无条件和版本守护的写入、字面编辑语义、行尾保留、结构化 `FsError` 错误码);`dsh-tool-fs` 中基于真实本地提供方的消费方接口(只 mock 模型/时钟,从不 mock 协作者);以及通过 `ctx.tools.execute()` 在有和没有 `dsh-fs-policy` 的情况下进行集成测试,通过从磁盘回读文件来验证世界状态,而非信任返回的 `ContentBlock[]`。观测状态/owner 推导策略在 `dsh-fs-policy` 中测试,不在此处。
本仓库曾踩过的防御性模式类别被直接固定:
- **原子写入临时文件安全。** 写入/编辑通过目标旁边一个私有随机 `0700` 目录中的独占 owner-only`'wx'``0o600`)临时文件暂存,失败时清理,最后原子 rename——与 bash 溢出文件规则一致,因为可预测的 world-readable 临时路径招致符号链接竞争和信息泄露。测试断言权限,并断言已存在的临时路径不会被覆盖;此原语是 seam 的常设要求。
- **通过符号链接的 `targetKey` 同一性。** 两个输入路径解析到同一 realpath 时共享一个观测状态条目:通过路径 A 的 `read` 满足通过符号链接路径 B 的 `edit` 的读后编辑守护,通过一个路径的陈旧写入可通过另一个路径检测到。
- **并发/陈旧竞争。** 对同一目标的两个并发写入/编辑操作确定性地收敛——一个成功,另一个被 `FS_STALE_VERSION` 拒绝——成功的编辑刷新记录状态,使同一 owner 的下一次编辑可以继续。
- **HMR热模块替换安全与 dispose资源释放。** dispose 后端的 fiber 会撤回 `ctx.fs` 提供方;后续的提供方以无继承状态启动。
## 曾考虑的替代方案
- **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。
- **单一合并包 `dsh-fs-tools`**seam 之前的形态;以与 bash 相同的接口/实现/消费方拆分理由否决,且合并名称从未成为公开接口。
- **观测状态放在 `ctx.fs` 上**:本 RFC 最初落地的形态;被 [split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [event-gate RFC](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。
## 后果
**`cwd` 可能被误认为沙箱。** 本地后端的基目录是解析默认值,而非自动的隔离边界。如果需要隔离,必须由后端契约或 `tools/execute` 上的权限/沙箱插件强制执行。
**接口可能变得过于本地化。** 如果 `ctx.fs` 返回 `absolutePath` 之类的字段,远程、沙箱或虚拟后端会变得尴尬。契约应暴露显示元数据,而不要求消费方理解宿主路径。
**接口可能变得过于薄。** 如果 `ctx.fs` 只镜像 `node:fs` 原语,`tool-fs` 将重新实现二进制检测、分页、原子写入和编辑语义,重新制造本 RFC 试图避免的耦合。
**编辑语义天然易受竞争影响。** 字面编辑是读-改-写操作;守护手段是后端的原子变更临界区加上可选的版本期望,因此并发编辑确定性地收敛——一个赢,另一个得到 `FS_STALE_VERSION`
**观测状态不属于 `ctx.fs`。** 记录执行上下文看到了什么是工作流策略,而非原始文件系统 I/O。本 RFC 最初将其放在文件系统 seam 内部split-fs-seam RFC 随后确立了沙箱/远程后端不应继承面向模型的观测策略,并将其移入 `dsh-fs-policy` 插件。提供方 seam 只保留写入/编辑安全在存储层真正需要的东西——后端铸造的版本令牌和可选的版本守护变更——而策略插件拥有 owner 推导、观测状态和基于 `fs/*` 事件的读后编辑门控。
**`resolve` 然后操作的形态每次调用多一次往返。** 每个工具可能先将路径解析为 `FsTarget`,再以单独的 `ctx.fs` 调用发起读取/写入/编辑。对本地后端来说这可以忽略(解析是内存中的路径规范化),但远程/沙箱后端可能将每步变成独立请求,使单次 `read` 变为两次网络往返。往返开销重要的后端可以在内部缓存或折叠解析,同时保持可观测契约不变。
**观测状态持久化被推迟。** 观测状态存在于内存中(`dsh-fs-policy` 内部的 `WeakMap`),因此恢复的会话保守地要求文件在写入/编辑前重新读取,直到未来的会话事件或持久化机制使观测可回放。
**错误码成为 seam 的一部分。** `FsError` 错误码使陈旧版本和观测失败可通过既有的结构化错误分类体系进行机器路由。代价是 `dsh-fs``dsh-llm` 导入共享的 `HarnessError` 基类;该依赖是有意为之且限于错误词汇。
**包拆分的成本前置。** 三包拆分在只有一个后端时就增加了样板代码。这是有意为之:文件系统访问是可能的沙箱/远程边界,在面向模型的工具发布后再改包接口代价更高。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-agent-lifecycle-and-ownership-seams.md: c37578020366dd40250c061e34a86d2de6907e19
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 1f3cb609da5508d22046f445e08a75dc5a7901cd

View File

@@ -0,0 +1,48 @@
# Agent Note: Agent lifecycle and ownership seams
Status: implemented
## Problem
Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through `ctx.agents`, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; `session/cancel` could not cancel queued-but-not-yet-started work; and `tool-bash` kept task ownership in a plugin-local `Map`, so an HMR reload could make an old task look unowned.
## Decision
Three seams: the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token.
### 1. Queue-aware `Agent.cancel(cause?)`
A new `cancel()` verb on the `Agent` interface — the single public stop primitive. (It originally shipped alongside a narrower step-only `abort()`; that verb was later removed as unused, leaving `cancel()` the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the active turn if any, and keeps a cause-less pre-run marker so a prompt cancelled before claim never runs while a later prompt remains independent. An effective call emits `agent/cancel-requested` with the typed `user | parent` cause before clearing or aborting; idle cancellation emits nothing and cannot strand the next prompt. `whenIdle()` reaches post-cancel quiescence, and ACP `session/cancel` maps to `user`. The [explicit turn-cancellation decision](2026-07-16-explicit-turn-cancellation.md) owns the current cause, signal-lifetime, and cooperative-settlement contract.
### 2. `AgentHandle` async disposer
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
### 3. Bash owner token in the seam
Background-task ownership moved from a `tool-bash` plugin-local `Map<string, Agent>` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.id` (the shared registry/session id) as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.id === ownerToken` (read via `ctx.get``onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
## Verification
These invariants hold and are pinned by tests:
- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown.
- `session/cancel` before a queued prompt starts prevents that prompt from running; a later accepted prompt remains an independent queued turn.
- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor).
- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber.
## Session owner tokens are unique among live agents
The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split.
## Alternatives considered
- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` seam — rejected: one read path, no redundant API.
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing removal of the store-owned append publication hooks against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md)).
## Consequences
This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. The simple synchronous `Agent.send()` ergonomics were preserved; the async lifecycle path is additive, for owners that need it.

View File

@@ -0,0 +1,48 @@
# RFC: Agent 生命周期与所有权 seam
Status: implemented
[English](2026-06-18-agent-lifecycle-and-ownership-seams.md) | 中文
## 问题
ACPAgent Client Protocol与 tool-bash 的若干限制是同一个缺失 seam 的症状:插件可以通过 `ctx.agents` 创建或恢复 agent智能体但无法独立拥有和 dispose资源释放单个 agent而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent却无法仅注销该会话的 agent`session/cancel` 无法取消已入队但尚未开始的工作;`tool-bash` 将任务所有权保存在插件本地的 `Map` 中,因此一次 HMR热模块替换重载就可能让旧任务看起来无主。
## 决策
三个 seam队列感知的 cancel、`AgentHandle` 释放器,以及 bash 所有者令牌。
### 1. 队列感知的 `Agent.cancel(reason?)`
`cancel()` 是唯一的公开停止原语。它清除已入队和 steering中途引导输入、中止正在进行的步骤并设置一个在每个轮次边界检查的轮次作用域标记。因此已入队的 prompt 在取消后无法启动,也无法吸收后续输入。`whenIdle()` 等待取消后的静默状态ACP 的 `session/cancel` 映射到此方法。对空闲状态的 cancel 不设置标记。
### 2. `AgentHandle` 异步释放器
`ctx.agents.create`/`resume``AgentFactory` 返回 `AgentHandle = { agent, dispose() }`。释放是消费方的能力;仅持有 `Agent` 的观察者无法将其拆除。调用方 fiber 和 factory 提供方也拥有该实例,所有路径共享一个 memoize 的拆除过程:停止循环、等待静默与刷写完成、分离 agent 和会话,然后解除其 scope。ID 在注册表条目分离后变为可复用。由配置创建的 agent 归 loop fiber 所有ACP 存储并 dispose 每个会话的 handle。
拆除顺序对持久性至关重要。会话生命周期与循环共享一个复合 Cordis effect因此 LIFO 释放会先停止循环并等待 `agent.done`,然后再分离会话。若使用兄弟 effect则会并发释放可能在关闭刷写之前就移除 append 钩子。释放通知被隔离,不会中断拆除链。
### 3. Bash seam 中的所有者令牌
后台任务的所有权属于执行器。`BashExecSpec.owner` 携带一个可选的不透明令牌,`ownerOf(id)` 读取它,`dsh-tool-bash` 在启动时盖上调用方的会话令牌。`bash_output``bash_kill` 拒绝不匹配的调用方;完成通知通过注册表按会话令牌定位存活的 agent。将所有权保存在任务上使得这道隔离在工具插件重载后依然有效。完成监听器仍然是 effect 作用域的,因此在重载间隙到达的通知仍可能被丢弃。
## 验证
- ACP 断连或会话关闭后,不留下任何已注册的 agent 或 session-store 条目,包括 `session/load` 与拆除竞争的情况。
- 在已入队的 prompt 启动前取消,能阻止该 prompt 运行或吸收下一条 prompt。
- 重载 `dsh-tool-bash` 不会让另一个会话读取或终止已有的后台任务,因为所有权保留在执行器上。
- 由配置创建的 agent 仍归 loop fiber 所有,因此非 ACP 演示无需显式管理 handle。
## 会话所有者令牌在存活 agent 中唯一
bash 所有者令牌依赖 `session.header.id` 在存活 agent 中的唯一性。并发的同 ID 操作可以私下准备,但 `SessionStore.enter()` 拒绝重复发布,失败的事务回滚。`tool-bash` 拥有比较策略bash seam 存储一个不透明的 `owner` 字符串,不对其做解释。
## 曾考虑的替代方案
- **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` seam否决。一条读取路径即可无需冗余 API。
- **为 agent 的会话生命周期使用兄弟 Cordis effect**否决。fiber 卸载时并发释放兄弟 effect`Promise.all`store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 RFC](../simplification/2026-06-20-public-agent-stop-surface.md))。
## 后果
本变更有意触及公开接口(`Agent``AgentFactory`、bash seam而非作为 ACP 的局部补丁。简洁的同步 `Agent.send()` 人体工学得以保留;异步生命周期路径是增量添加的,供需要它的所有者使用。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-session-surface.md: bcad236eaa2bead3c140e7a12772729710d43f83
2026-06-18-session-surface.zh.md: 2ec60501c183d1bdab22ceb67b7f6aa0040ae82b

View File

@@ -0,0 +1,71 @@
# Agent Note: Session surface — an ordered projection over the event log
Status: implemented
## Problem
The event log is authoritative, but history manipulation had no durable shared mechanism. Plugins such as compaction would otherwise rewrite derived requests through order-sensitive listeners, leave no provenance, and require repeated changes to `deriveMessages()`.
## Decision
Add a **surface** — a derived, cached order of event sequences (the subset of events that produce LLM messages) — maintained by `surfaceOp` markers in the event log.
### Two new top-level fields on `SessionEvent`
Every `SessionEvent` gains two optional fields (structural metadata, like `seq`/`time`):
- **`sourceEventSeqs?: number[]`** — seq numbers of events that are provenance sources (e.g., the `assistant/chunk` seqs that built an `assistant/message`, or the surface nodes shadowed by a compaction marker). A present `[]` is valid only on `assistant/message` and records a known empty provider stream; omission there means legacy or otherwise unrecorded provenance. Other surface events require a non-empty list when the field is present. Provenance is a core design principle; without it, the replace-range operation cannot be validated on replay.
- **`surfaceOp?: SurfaceOp`** — how this event entered the surface. Absent for non-surface events.
### SurfaceOp: two operations
```ts
export type SurfaceOp =
| 'append' // normal tail append
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
1. **Append** — add the new event seq to the tail. Used by `user/message`, `assistant/message`, `tool/result`, `context/message`, `steering/message`. The loop passes `surfaceOp: 'append'` on all such appends and records `sourceEventSeqs` where applicable: every successful `assistant/message` records its complete `assistant/chunk` source set, including `[]`, while `tool/result` records its `tool/call` source.
2. **Replace** — remove entries from `start` through `end` (both inclusive) and insert the new event seq in their place. Both `start` and `end` must be present in the current surface; `start === end` replaces one entry. The event's `sourceEventSeqs` must contain every shadowed surface seq. The shadowed events remain in the log but are no longer on the surface.
### SurfaceManager: delta-based, not full rebuild
A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order.
Delta processing is O(1) when no new events and O(new events) when new events arrive.
`deriveMessages()` uses the surface when surface markers exist, falling back to the existing linear scan for sessions without markers (backward compatibility).
### Persistence
The new fields are serialized as top-level JSON properties. The JSONL backend requires zero changes — `JSON.stringify`/`JSON.parse` preserve everything transparently. The SQLite backend's `events` table carries two nullable TEXT columns (`source_event_seqs`, `surface_op`). The on-disk `SCHEMA_VERSION` is bumped to reflect the column set, and — per the pre-release bump-and-reject policy — a database written by any other build is REJECTED on open rather than migrated (there is no persisted user data to upgrade). The session format `version` is pinned at `SESSION_FORMAT_VERSION = 0` (the "unstable / pre-release" stance): the optional surface fields are absorbed without bumping it.
### Crash recovery
The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls after a crash. These closers carry `surfaceOp: 'append'` and `sourceEventSeqs` pointing to the orphaned `tool/call` event, so the rehydrated surface is valid.
### Invariants
`Session` validates `sourceEventSeqs` and `surfaceOp` at the always-on seed/append boundary: only `assistant/message` may use an empty provenance list; references are unique, earlier, and known; replacement endpoints exist in surface order; and provenance covers every shadowed node. These are single-record acceptance and storage-projection rules, not optional invariant-service contributions.
Every surface-eligible event must carry `surfaceOp` or it would disappear from derived history. Typed `append` overloads enforce this for literal event types; runtime checks in `append` and the seed constructor cover widened unions and loaded logs. Invalid seeds are rejected rather than upgraded under the pre-release format policy.
## Alternatives considered
- **Per-plugin `agent/request` wrapping** (the pre-surface pattern for history manipulation) — listener-ordering fragility, no durable record of what was changed, and every new manipulation forces another change to core `deriveMessages()`.
- **Half-open `[start, endExclusive)` replace ranges** — rejected: endpoints are named by surface event seqs, and single-entry replacement (`start === end`) reads naturally with inclusive semantics.
- **Linked node objects plus a seq map** — rejected: production did not read predecessor links, the only successor use was the next array position, and replacement already required linear `indexOf` lookup. A single seq array preserves the same asymptotic behavior with one representation to validate.
- **Full rebuild behind a dirty flag** instead of delta processing — O(N²) over a session's lifetime: every single-event append would rescan all prior events.
## Consequences
- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants).
- **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance.
- **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration).
- **`packages/session-persistence/session-persistence-jsonl`**: No changes required.
- **`packages/session-persistence/session-persistence`**: Abstract interface unchanged.
The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
A `tool/result` replacement may rewrite exactly one current `tool/result` and must preserve every data field except `content`. Session acceptance enforces this rule together with positional range and provenance validation, independent of optional diagnostic plugins.

View File

@@ -0,0 +1,71 @@
# RFC: 会话 surface——基于事件日志的链表用于 LLM 消息派生
Status: implemented
[English](2026-06-18-session-surface.md) | 中文
## 问题
事件日志是权威数据源但历史操纵此前没有持久化的共享机制。如果没有这样的机制上下文压缩context compaction等插件只能通过顺序敏感的监听器改写派生请求不留溯源信息且每次新增操纵都要反复修改 `deriveMessages()`
## 决策
新增一个 **surface**一条派生的、缓存的链表由「surface 节点」(事件中产出 LLM大语言模型消息的子集组成通过事件日志中的 `surfaceOp` 标记维护。
### `SessionEvent` 新增两个顶层字段
每个 `SessionEvent` 获得两个可选字段(结构性元数据,与 `seq`/`time` 同级):
- **`sourceEventSeqs?: number[]`**:作为溯源来源的事件 seq 编号(例如构成 `assistant/message` 的各 `assistant/chunk` 的 seq或被压缩标记遮蔽的 surface 节点。溯源是核心设计原则没有它replace-range 操作在回放时无法被验证。
- **`surfaceOp?: SurfaceOp`**:该事件如何进入 surface。非 surface 事件不携带此字段。
### SurfaceOp两种操作
```ts
export type SurfaceOp =
| 'append' // normal tail append
| { op: 'replace'; start: number; end: number } // shadow [start, end] inclusive
```
1. **Append**:在尾部追加一个新节点。`user/message``assistant/message``tool/result``context/message``steering/message` 使用此操作。agent loop智能体循环在所有此类追加上传入 `surfaceOp: 'append'`,并在适用时附带 `sourceEventSeqs`(例如 `assistant/message` 记录其 `assistant/chunk` 来源;`tool/result` 记录其 `tool/call` 来源)。
2. **Replace**:移除从 `start``end`(两端包含)的节点,并在其位置插入一个新节点。`start``end` 都必须是当前 surface 上有效的 surface 节点 seq`start === end` 表示替换单个节点。该节点的 `sourceEventSeqs` 必须包含所有被遮蔽的 surface 节点。被遮蔽的事件仍留在日志中,但不再出现在 surface 上。
### SurfaceManager基于增量而非全量重建
`SurfaceManager` 类(`Session` 私有)维护缓存的链表。它跟踪 `_lastProcessedSeq`,仅处理**增量**(自上次访问以来的新事件),而非重新扫描整个日志。由于日志是仅追加的,先前的事件不会改变;种子日志只是在首次访问时折叠的初始增量。
无新事件时增量处理为 O(1),有新事件到达时为 O(新事件数)。
`deriveMessages()` 在存在 surface 标记时使用 surface对没有标记的会话回退到既有的线性扫描向后兼容
### 持久化
新字段作为顶层 JSON 属性序列化。JSONL 后端无需任何改动:`JSON.stringify`/`JSON.parse` 透明地保留一切。SQLite 后端的 `events` 表新增两个可空 TEXT 列(`source_event_seqs``surface_op`)。磁盘上的 `SCHEMA_VERSION` 递增以反映列集变化,并且按照预发布的 bump-and-reject 策略,由其他构建写入的数据库在打开时被拒绝而非迁移(没有需要升级的持久化用户数据)。会话格式 `version` 固定为 `SESSION_FORMAT_VERSION = 0`(「不稳定/预发布」立场):可选的 surface 字段被吸收而不递增版本号。
### 崩溃恢复
`repair.ts` 模块在崩溃后为孤立的工具调用合成 `tool/result` 闭合事件。这些闭合事件携带 `surfaceOp: 'append'` 和指向孤立 `tool/call` 事件的 `sourceEventSeqs`,确保重建的 surface 有效。
### 不变式
开发模式下的不变式插件验证:`sourceEventSeqs` 引用(非空、无重复、引用更早的事件、引用已知 seq以及 `surfaceOp`replace 的 `start ≤ end`、两个端点都在被跟踪的 surface 上、范围在 surface 位置上不反转、`sourceEventSeqs` 包含该范围遮蔽的每个节点)。
每个 surface 可达事件都必须携带 `surfaceOp`,否则它将从派生历史中消失。类型化的 `append` 重载对字面事件类型强制执行此规则;`append` 和种子构造函数中的运行时检查覆盖宽化联合类型和加载的日志。按照预发布格式策略,无效的种子被拒绝而非升级。
## 曾考虑的替代方案
- **逐插件的 `agent/request` 包装**surface 之前的历史操纵模式):监听器排序脆弱、无法持久记录改动内容,且每种新操纵都迫使核心 `deriveMessages()` 再次修改。
- **半开区间 `[start, endExclusive)` 的 replace 范围**否决。surface 是双向链表,端点自然以节点 seq 命名,单节点替换(`start === end`)在闭区间语义下读起来更自然。
- **脏标记后全量重建**替代增量处理:在会话生命周期内为 O(N²),每次单事件追加都要重新扫描所有先前事件。
## 后果
- **`packages/core/session`**:新增 `surface.ts``SurfaceManager`)、新类型(`SurfaceOp``SurfaceIntent`)、`SessionEvent` 新字段、修改 `append()`(第三个必选参数 `SurfaceIntent`)、重构 `deriveMessages()`(以 surface 遍历作为唯一派生路径、surface 感知的 `repair.ts`。种子构造函数拒绝缺少 `surfaceOp` 标记的 surface 可达种子事件(见「不变式」一节)。
- **`packages/core/agent-loop`**:所有 surface 可达的追加操作传入 surface 选项。收集 chunk seq 用于 `assistant/message` 溯源;捕获 `tool/call` seq 用于 `tool/result` 溯源。
- **`packages/session-persistence/session-persistence-sqlite`**`events` 表新增两个可空 TEXT 列(`source_event_seqs``surface_op``SCHEMA_VERSION` 递增bump-and-reject无迁移
- **`packages/support/invariants`**surface 相关验证规则。
- **`packages/session-persistence/session-persistence-jsonl`**:无需改动。
- **`packages/session-persistence/session-persistence`**:抽象接口不变。
Surface 是未来历史操纵的基础。压缩或 tool-result-prune 插件追加一个既有的消息产出事件类型(例如一条携带摘要的 `user/message`),附带 `surfaceOp: { op: 'replace', start, end }` 和覆盖被遮蔽节点的 `sourceEventSeqs`——新节点在 surface 上取代该范围的位置,而插件自身的 trace 事件(如 `compaction/start``compaction/end`)不进入 surface。回放以确定性方式保留该决策。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-18-shared-persistence-write-coordinator.md: 7648c6f8e43fb8c78f881838aa4d476aba209dca
2026-06-18-shared-persistence-write-coordinator.zh.md: 19f583c2f3d78fcdac1378cfe00e49c0be943eb1

View File

@@ -0,0 +1,46 @@
# Agent Note: Shared persistence write coordinator
Status: implemented
English | [中文](2026-06-18-shared-persistence-write-coordinator.zh.md)
## Problem
`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed.
## Decision
Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its four public service methods (`create`/`append`/`load`/`list`) to it.
Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all.
The coordinator retires each live session from its `session/disposed` notification: it waits for that exact Session object's initialization, serializes a final drain, and then removes the owned state, buffer, and init entries. Failed drains retain their buffers for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still the current tail, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters the write-path listeners before awaiting all admitted retirements, remaining buffers, and chains, then closes the backend.
### The hook interface (`PersistenceBackend<TornMarker>`)
Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage:
- `name` — backend label for the dispose-failure `AggregateError`.
- `loadStored(id)` — read a stored prefix by id, scanning ANY storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Used by resume/load and, via `!== undefined`, the create-collision probe.
- `loadLive(id, cwd)` — read a stored prefix SCOPED to `cwd`. **Deliberately distinct from `loadStored`**: HMR live-adoption must only adopt a persisted log at the SAME cwd as the live session; a same-id log at a different cwd is a collision, not a resume. Collapsing the two reintroduces a cross-cwd adoption bug. SQLite ignores `cwd`.
- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook).
- `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`).
- `list()` — list all stored metadata.
- `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error.
### The opaque torn marker
The single design choice that keeps the seam clean: the crash-repair "where is the torn tail" token is OPAQUE to the coordinator. The coordinator computes the synthetic closers (it owns `interruptedTurnClosers` from `dsh-session`), but it only ever tests `tornMarker !== undefined` and passes the value straight back to `commitRepair` — it never inspects it. Each backend picks its own marker type: JSONL carries the byte offset to truncate to plus any complete events decoded from an incomplete final frame, while SQLite carries the seq to delete from. The coordinator therefore knows neither byte lengths nor frame recovery state.
## Testing
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
## Alternatives considered
- **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all.
- **A wider hook surface** — each candidate hook folded away: there is no separate `materialize` hook (the materialize-write must commit atomically with the first event batch inside `appendBatch`), no separate create-collision probe (it is `loadStored(id) !== undefined`), and no coordinator pass-through for `list()` (listing needs none of the orchestration).
## Consequences
The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.

View File

@@ -0,0 +1,44 @@
# RFC: 共享持久化写入协调器
Status: implemented
[English](2026-06-18-shared-persistence-write-coordinator.md) | 中文
## 问题
`dsh-session-persistence-jsonl``dsh-session-persistence-sqlite` 有意在不同存储介质上证明同一份 `SessionPersistence` 契约但它们的写入路径编排是重复的per-session 状态、`session/created` 接管、后端特定的前缀读取、write-behind 缓冲区、序列化的 flush 链、HMR热模块替换种子注入与 dispose资源释放排空。纯粹的种子前缀碰撞检查与可序列化守卫已迁入 seam 包;剩余的编排仍然对正确性要求很高,且同样的修复被应用了两次。代码级 diff 表明两个后端在全部这些逻辑上要么字节相同、要么算法相同:四个 map`states`/`buffers`/`chains`/`inits`)、`installWritePath``initFor``onCreated` 的四种分支、`flush``drain``serialize``adopt``adoptLivePrefix``assertVersion`,以及 `create`/`append`/`load` 的骨架。唯一的差异在于存储原语(写字节 vs. INSERT 行)。
## 决策
将一个后端无关的 `PersistenceCoordinator` 提取到 `dsh-session-persistence` 中。协调器统一拥有编排逻辑;每个第一方后端组合一个协调器实例(`new PersistenceCoordinator(ctx, this)`),实现一个小型 `PersistenceBackend` 钩子接口,并将其四个公开服务方法(`create`/`append`/`load`/`list`)委托给协调器。
组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。本 RFC 的风险——「协调器不得让非常规后端与继承层级作斗争」——由此规避:后端只暴露钩子;它无法触及协调器的私有编排状态,且公开的 `SessionPersistence` 服务形状不变,因此第三方后端仍然可以完全不使用协调器、直接实现抽象服务。
### 钩子接口(`PersistenceBackend<TornMarker>`
六个方法(五个必需 + 一个可选的生命周期钩子)——协调器与存储之间唯一的 seam
- `name`——后端标签,用于 dispose 失败时的 `AggregateError`
- `loadStored(id)`——按 id 读取已存储的前缀扫描任何存储范围JSONL 的每个 cwd bucketSQLite 的 id 全局唯一)。用于恢复/加载,以及通过 `!== undefined` 进行创建碰撞探测。
- `loadLive(id, cwd)`——读取限定于 `cwd` 的已存储前缀。**与 `loadStored` 有意区分**HMR live-adoption 只能接管与存活会话处于同一 cwd 的持久化日志;同 id 但不同 cwd 的日志是碰撞而非恢复。合并二者会重新引入跨 cwd 接管 bug。SQLite 忽略 `cwd`
- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——崩溃不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。
- `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync先截断再追加SQLite 在一个事务中完成 DELETE+INSERT。用于 `load`(截断 + 合成 closers和 live-adoption仅截断`closers = []`)。
- `list()`——列出所有已存储的元数据。
- `close?()`——可选的生命周期清理SQLite 关闭 db 句柄JSONL 省略),在 dispose effect 中于静默排空之后被 await因此 close 失败不会掩盖排空错误。
### 不透明的 torn marker
保持 seam 整洁的唯一设计选择:崩溃修复中「损坏尾部在哪里」的 token 对协调器是不透明的。协调器计算合成 closers它拥有来自 `dsh-session``interruptedTurnClosers`),但它只测试 `tornMarker !== undefined` 并将值原样传回 `commitRepair`——从不检视其内容。每个后端选择自己的 marker 类型JSONL 使用要截断到的字节偏移SQLite 使用要从其开始删除的 seq两者恰好都是 `number`。JSONL 后端将其 `committedBytes < buffer.byteLength` 比较折叠在钩子内部,因此返回的 marker 已经是 `number | undefined`;如果不做这层折叠,协调器就必须了解字节长度。
## 测试
共享的 `runPersistenceContract`(公开 API 契约)继续为每个后端运行。新增的 `runCoordinatorContract``tests/coordinator-contract.ts`覆盖写入路径编排——接管、HMR、碰撞、dispose 排空、崩溃尾部修复——通过 `CoordinatorFixture`(内存参考实现 + jsonl + sqlite为每个后端运行一次。各后端自身的测试规格缩减为仅覆盖存储机制JSONL路径安全、fsync 回滚、bucket 列举SQLiteschema 版本、`scanRows`、事务回滚)。每个真实后端有一个经由协调器的 torn-tail→load→`commitRepair` 测试(通过 `corruptTail` fixture测试前置数据钩子确保协调器的 torn-marker 修复分支在 100% per-file 门禁下被覆盖——契约崩溃测试只产生合成 closers 而不产生 torn marker因此无法触达该分支。
## 曾考虑的替代方案
- **后端继承的基类**——否决,改用组合:后端只暴露钩子,无法触及协调器的私有编排状态,且第三方后端仍可完全不使用协调器、直接实现抽象服务。
- **更宽的钩子面**——每个候选钩子都被折叠掉:没有单独的 `materialize` 钩子(物化写入必须在 `appendBatch` 内与首批事件原子提交);没有单独的创建碰撞探测(即 `loadStored(id) !== undefined``list()` 也不经由协调器透传(列举不需要任何编排)。
## 后果
协调器增加了一层间接和一个不透明的 torn marker但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。其钩子面保持窄小碰撞检查复用 `loadStored`,物化保持在 `appendBatch` 内原子完成,列举绕过协调器。新后端只需实现存储原语,而无需复制事件-缓冲区-flush 生命周期。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-branded-ids.md: aab47a1413451edb707ae797e5b99cda6e34efad
2026-06-20-branded-ids.zh.md: 1ccfda3282069510bcdbfbddf4546305fd0923a5

View File

@@ -0,0 +1,67 @@
# Agent Note: Branded IDs everywhere they belong
Status: implemented
## Problem
The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today.
**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md).
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.
## Decision
A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy.
- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives).
- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.)
- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map<SessionId, Session>`, `Map<SessionId, Agent>`, `get(id: SessionId)`, `Map<CallId, …>`, ACP's `SessionId` surface, and the coordinator's `Map<SessionId, …>`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields.
Illustrative shape (the factory pattern is identical to the three existing brands):
```ts ignore-check
import type { Branded } from '@deepseek-ai/dsh-brand'
/** A background bash task handle (generated `bash-N` by the local executor). */
export type BashTaskId = Branded<'BashTaskId'>
export function BashTaskId(id: string): BashTaskId {
return id as BashTaskId
}
/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
export type OwnerToken = Branded<'OwnerToken'>
export function OwnerToken(id: string): OwnerToken {
return id as OwnerToken
}
```
## Alternatives considered
### Why not typing `owner` as `SessionId`?
The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (interface `dsh-bash`, implementation `dsh-bash-local`, consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/bash/bash/src/types.ts`). Typing the seam's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-bash` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling.
## Out of scope / possible extensions
Kept deliberately narrow per the "not every string needs a brand" policy. Each of these is a plausible future brand, deferred with a reason, not a commitment:
- **`ModelId`** (`GenerateOptions.model`, the `LlmService` adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this Agent Note's blast radius focused.
- **`ToolName`** (the `ToolRegistry` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand.
- **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything.
- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded<string>` 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 Agent Note, not bundled into this type-only pass.
## Verification
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts.
## Consequences
- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above.
- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This Agent Note does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id.
- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this Agent Note errs toward the ids that are model-facing or used for access control.

View File

@@ -0,0 +1,69 @@
# RFC: 在所有应有之处使用 branded ID
Status: implemented
[English](2026-06-20-branded-ids.md) | 中文
## 问题
harness 已经为三个标识符做了 brand 处理:`CallId``packages/llm/llm/src/brand.ts`)、`SessionId``packages/core/session/src/types.ts`)和 `AgentId``packages/core/agent/src/types.ts`),使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制由纯类型包package `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md)),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*"Branding 用于跨包边界且可能被混淆的 id不是每个 string 都需要 brand。"* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。
**缺口 1bash seam 中未 brand 的 ID。** `BashTask.id` 以及所有执行器/工具边界使用裸 `string`,尽管生成的值与默认 session id 具有相同的 `name-N` 形状。模型还通过 `task_id` 返回该值,因此混淆 task id 和 session id 既类型正确又可达。
bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string``BashExecSpec.owner: string | undefined``packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent智能体`session.header.id``callerToken = (exec) => exec.agent?.session.header.id`,位于 `packages/bash/tool-bash/src/index.ts`),即一个穿着 `string` 外衣的 `SessionId`。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是一个跨会话隔离 bug而当前类型系统无法捕获。这正是 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案所称的以 `session.header.id` 作为 owner 的别名缺口("bash owner-token alias hole")。
**缺口 2既有 brand 的侵蚀。** `CallId``SessionId``AgentId` 在注册表 map、公开查找参数、ACP 会话跟踪和持久化协调器中退化为裸 string。在查找边界丢弃 brand 会使其主要保护失效。
## 决策
纯类型变更。Brand 是零开销 cast运行时行为、序列化、比较和协议格式wire format均不变。工作分三部分全部遵循既有的"不是每个 string 都需要"策略。
- **为 bash task id 加 brand。** 在 `packages/bash/bash/src/types.ts`*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId`/`AgentId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-bash` 仅依赖它就能为自己的 id 加 brand而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id``BashExecutor` seam 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand以及 `dsh-tool-bash` 的校验/访问面(`validateTaskId` 返回 `BashTaskId``task_id` 在模型 string 到达的工具边界处被 brand
- **铸造独立的 `OwnerToken` brand。** 在 `packages/bash/bash/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined``dsh-tool-bash` 消费方在边界处将 agent 的 `session.header.id`(一个 `SessionId`cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash seam 从不导入 `dsh-session`。(理由见下一节。)
- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>``get(id: SessionId)``Map<AgentId, Agent>``Map<CallId, …>`、ACP 的 `SessionRecord.sessionId: SessionId` 接口、协调器的 `Map<SessionId, …>`。这是 diff 中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
示意形状(工厂模式与已有的三个 brand 完全一致):
```ts ignore-check
import type { Branded } from '@deepseek-ai/dsh-brand'
/** A background bash task handle (generated `bash-N` by the local executor). */
export type BashTaskId = Branded<'BashTaskId'>
export function BashTaskId(id: string): BashTaskId {
return id as BashTaskId
}
/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
export type OwnerToken = Branded<'OwnerToken'>
export function OwnerToken(id: string): OwnerToken {
return id as OwnerToken
}
```
## 曾考虑的替代方案
### 为什么不把 `owner` 类型标注为 `SessionId`
执行器将 ownership 视为不透明的,不应依赖 session 模型。独立的 `OwnerToken` 保留了这一边界,同时防止裸 string 或 task id 被当作 owner 传入。`dsh-tool-bash` 拥有访问策略,由它执行来自 `SessionId` 的唯一转换。
## 不在范围内 / 可能的扩展
遵循"不是每个 string 都需要 brand"的策略,刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺:
- **`ModelId`**`GenerateOptions.model``LlmService` 适配器注册表的键一个真正的跨包查找键config → agent → llm → adapter合理的下一个 brand仅为控制本 RFC 的影响范围而暂不纳入。
- **`ToolName`**`ToolRegistry` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
- **`ErrorCode`**`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id如果要做string 字面量联合类型比 brand 更合适。
- **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string``Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。
- **带校验的构造**brand 工厂是纯 cast无运行时检查且每个边界ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算"格式错误"?失败时怎么办?),应在独立 RFC 中处理,不应捆绑进这次纯类型变更。
## 验证
`BashTaskId` 和 `OwnerToken` 定义在 `dsh-bash` 中,贯穿执行器、本地实现和面向模型的工具,且未添加 `dsh-session` 依赖。集合、公开参数和导出签名对 `CallId`、`SessionId`、`AgentId` 或 `BashTaskId` 使用相应的 brand 而非裸 `string`来自提供方、ACP 和模型的原始输入通过 brand 工厂进入,而非散落的 cast。
## 后果
- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam接口 + 实现 + 消费方)以及 ACP session-id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。变更可观察地为纯类型变更——无快照或 e2e 行为差异。它与 [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) 提案相邻(两者都触及 session-id / owner-token 边界);如果该提案落地,`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的* session id 只要仍是合法的 string就和以前一样能通过类型检查器。本 RFC 不关闭这个缺口(见"不在范围内")——它只阻止这类*类别*错误:传入错误*种类*的 id。
- **"在哪里停下"仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string"可能被混淆"的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本 RFC 倾向于面向模型或用于访问控制的 id。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-extract-example-app-packages.md: b0dd1fe32b2389578d3f3bd3672435923eb1a5d1
2026-06-20-extract-example-app-packages.zh.md: 9f47c98c9c54357a304490e3d4075fad6cea855a

View File

@@ -0,0 +1,57 @@
# Agent Note: Extract example apps into packages
Status: implemented
English | [中文](2026-06-20-extract-example-app-packages.zh.md)
## Problem
An example folder is supposed to be *thin* — the variable wiring of a demo, not the demo's machinery. Before this change it was thick. Each example carried a hand-rolled `start.ts` boot bootstrap, an infra preamble (`timer`, and — for the stdio demos — `logger` + `hmr`), nested includes of three shared YAML fragments (`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`), and per-example `agent-loop`/persistence/system-prompt config. The actual app — the spine of services every agent needs — was spread across the leaf and those includes.
The leaf configs also owned coupled front doors. ACP requires stdout purity and creates agents through `session/new`; terminal and Headless apps pre-create `main` but have different process I/O contracts. Prose warnings were the only guard against combining these incorrectly, while three `start.ts` files duplicated the Loader bootstrap and lifecycle code.
## Decision
Each example is now **mostly an invocation of an app package**, splitting the wiring along the existing [interface / implementation / consumer seam](2026-06-13-capability-seams.md): the **app package owns the composition**, the leaf `cordis.yml` owns only the **swappable choices** (which LLM adapter, which bash executor, model, prompt, persistence root).
- **`@deepseek-ai/dsh-agent-spine-demo`** ([packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo)) composes the providerless, executor-less, UI-less spine and forwards the loop's agent-list config. Its dependency on the concrete loop is intentional because this package composes the spine rather than extending it; swapping the loop means supplying another bundle.
- **`@deepseek-ai/dsh-tui-demo`**, **`@deepseek-ai/dsh-cli-demo`**, and **`@deepseek-ai/dsh-acp-demo`** bake in their process roles. TUI includes the full-screen UI and a pre-created `main`; Headless includes the one-shot driver and a pre-created `main`; ACP includes the bridge and no pre-created agent. All three include JSONL persistence and omit stdout loggers.
- **`start.ts` is gone.** Each app package exposes a bin; the `demo:*` scripts invoke it. Loader boot, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); the thin self-executing entries are driven by keyless Loader-path tests.
- **Each leaf `cordis.yml` collapses** to backends, optional product tools, and one app entry carrying the app config. TUI and Headless route model/session choices onto a pre-created agent; ACP routes the initial provider/model onto its bridge.
- **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-spine-demo`.
`bash-local` and the LLM adapter stay **leaf choices**: the bundle ships `tool-bash` (the consumer schema), the leaf picks the executor implementation, so a sandboxed executor or replay adapter swaps in without touching the app.
### Amendment on implementation: `hmr` stays a leaf entry
The proposal listed `hmr` among the interactive app's baked-in front-door cluster. Validating against the code, baking `hmr` into the app package fights Cordis in two ways, so it ships as a **leaf `cordis.yml` entry** instead:
1. `@cordisjs/plugin-hmr` is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader` service, so it can only run in the real `demo:*`/bin subprocess, never in the in-process unit/coverage tier.
2. The in-process test tier (vitest) cannot even *import* the vendored `hmr` module (its class-decorator `@Inject` form fails under Vite's transform), so a package whose `apply` statically imported it could never satisfy the per-file 100% coverage gate on its headline function.
Crucially, `hmr` is not a stdout-purity footgun: a stray entry in the ACP config does not corrupt JSON-RPC frames. Every shipped app omits a stdout console logger; the app or protocol driver alone owns stdout.
## Alternatives considered
### Why not keep the wiring in shared YAML includes?
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:tui`, `demo:headless`, 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](../../../../docs/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.** The spine lives behind a bundle, so seeing the whole tree means opening `dsh-agent-spine-demo`. The app package's README carries that teaching weight.
- **A layer of indirection.** "What does this demo load?" becomes a package read, not a single YAML scan.
## Related
- Supersedes [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md): renaming `base.yml` to the providerless core is moot once the spine moves into `dsh-agent-spine-demo` and the `base*.yml` files are deleted.
- Builds on the [capability-seams](2026-06-13-capability-seams.md) interface/implementation/consumer split — backends and presentation stay leaf choices; the spine is the shared bundle.
- Complements [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md): the new app/core packages slot into existing groups under that hierarchy (`core` for the reusable spine bundle, `ui` for the app-specific front doors).
- The later [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns the final TUI/Headless split and removes the line-oriented and mock-only leaves.

View File

@@ -0,0 +1,57 @@
# RFC: 将示例应用提取为独立包
Status: implemented
[English](2026-06-20-extract-example-app-packages.md) | 中文
## 问题
示例目录本应是*精简的*——只包含演示的可变接线,而非演示的基础设施。在此次变更之前,它是臃肿的。每个示例都携带一份手写的 `start.ts` 启动引导、一段基础设施前导(`timer`,以及 stdio 演示所需的 `logger` + `hmr`(热模块替换))、三个共享 YAML 片段的嵌套引用(`base.yml` / `base-core.yml` / `acp-agent/acp-tail.yml`),还有各示例自身的 `agent-loop`/persistence/system-prompt 配置。真正的应用——每个 agent智能体都需要的服务主干——散落在叶子配置和那些 include 中。
叶子配置还拥有一个耦合的前门。ACPAgent Client Protocol要求 stdout 纯净,并通过 `session/new` 创建 agentstdio 则需要一个控制台 logger 和一个预创建的 `main`。防止错误组合的唯一屏障是文档中的文字警告,而三个 `start.ts` 文件重复着 Loader 引导和生命周期代码。
## 决策
每个示例现在**主要是对一个应用包package的调用**,沿着既有的[接口 / 实现 / 消费方 seam](2026-06-13-capability-seams.md) 拆分接线:**应用包拥有组合**,叶子 `cordis.yml` 只拥有**可替换的选择**(哪个 LLM大语言模型适配器、哪个 bash 执行器、模型、提示词、持久化根目录)。
- **`@deepseek-ai/dsh-agent-spine-demo`**[packages/examples/agent-spine-demo](../../../../packages/examples/agent-spine-demo))组合了不含 provider、不含执行器、不含 UI 的主干,并转发 agent loop智能体循环的 agent 列表配置。它对具体 loop 的依赖是有意为之,因为该包组合的是主干而非扩展主干;替换 loop 意味着提供另一个 bundle。
- **`@deepseek-ai/dsh-stdio-demo`**[packages/examples/stdio-demo](../../../../packages/examples/stdio-demo))和 **`@deepseek-ai/dsh-acp-demo`**[packages/examples/acp-demo](../../../../packages/examples/acp-demo)各自内置了前门。Stdio 包含 `ui-stdio`、控制台 logger 和 `main`ACP 包含 bridge 和 JSONL 持久化,但不含 stdout logger 或预创建的 agent。叶子可以添加插件但安全的组合现在是默认产物。
- **`start.ts` 已移除。** 每个应用包暴露一个 `bin``dsh-stdio-demo` / `dsh-acp-demo``demo:*` 脚本调用它(例如 `dsh-stdio-demo ./cordis.yml`。Loader 引导尾部、`.env` 加载和快速失败守卫位于共享的 [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) 包(在逐文件覆盖率门禁下有单元测试——见[共享应用 bin 的启动胶水](../simplification/2026-07-04-share-app-bin-boot-glue.md));每个 bin 是一个精简的自执行组合基于这些辅助函数加上其应用特有的生命周期逻辑ACP bin快照模式选择与 stdin-dispose`bin.ts` 文件本身仍被排除在覆盖率之外(自执行 CLI命令行界面入口与旧的 `start.ts` 性质相同),由 keyless 的 Loader 路径测试驱动。
- **每个叶子 `cordis.yml` 精简为**后端 + 配置LLM 适配器(带 apiKey/models 的 `llm-deepseek`,或 `llm-replay`、bash 执行器(`bash-local`、stdio 演示的 `hmr`(见下方修正),以及一个承载应用配置的 app 条目(模型、系统提示词、持久化根目录——以应用包自身的 `Config` 形式暴露由它将各值路由到应用接线的目标位置stdio 路由到预创建的 agentacp 路由到 bridge 插件)。
- **echo-agent 折叠到 `dsh-stdio-demo` 上**,将 LLM 后端替换为本地的 `mock-llm`,并在叶子层添加本地的 `echo-tool`(加上 `bash-local`,由主干的 `tool-bash` 注入)——这是「替换后端、保留应用」的干净示范。`mock-llm.ts` / `echo-tool.ts` 作为示例本地的教学插件保留。
- **`base.yml``base-core.yml``acp-agent/acp-tail.yml` 已退役**——它们共享的主干现在位于 `dsh-agent-spine-demo` 中。
`bash-local` 和 LLM 适配器仍然是**叶子选择**bundle 提供 `tool-bash`(消费方 schema叶子选择执行器实现因此沙箱执行器或回放适配器无需触碰应用即可替换。
### 实现修正:`hmr` 保留为叶子条目
提案最初将 `hmr` 列入 stdio 应用内置的前门集群。对照代码验证后发现,将 `hmr` 内置到 `dsh-stdio-demo` 包中会在两个方面与 Cordis 冲突,因此改为作为**叶子 `cordis.yml` 条目**交付:
1. `@cordisjs/plugin-hmr` 是一个仅限 Loader、仅限子进程的开发插件——其构造函数在没有 `node --expose-internals` 和活跃的 `loader` 服务时会抛出异常,因此只能在真实的 `demo:*`/bin 子进程中运行,不能在进程内的单元/覆盖率测试层运行。
2. 进程内测试层vitest甚至无法*导入* vendor 的 `hmr` 模块(其 class-decorator `@Inject` 形式在 Vite 的 transform 下会失败),因此一个 `apply` 静态导入了它的包永远无法满足其主函数的逐文件 100% 覆盖率门禁。
关键在于,`hmr` **不是**像控制台 logger 那样的 stdout 纯净隐患ACP 配置中误加 `hmr` 不会破坏 JSON-RPC 帧,因此将它留在叶子层不会损失耦合论证所关注的安全性。**logger**真正的耦合点保持内置stdio 应用包含它ACP 应用省略它。
## 曾考虑的替代方案
### 为什么不继续用共享 YAML include 来管理接线?
旧的 `base*.yml`/`acp-tail.yml` include 已经去重了*配置*,但 YAML include 无法**封装**前门耦合——它只能在注释中描述,并信任每个叶子遵守。它也无法拥有 `bin`,因此启动胶水一直在三个 `start.ts` 文件中重复。包将「ACP 应用绝不向 stdout 输出日志」从文字警告变成了产物的属性:叶子中不存在可以写错的 logger 条目。
## 验证
- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。
- `demo:echo``demo:repl``demo:acp` 调用应用包的 bin。
- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。
- ACP 回放 transcript文本记录保持不变因为插件集合和加载顺序未改变。
## 后果
- **裸插件树的教学性。** echo-agent 内联的 `cordis.yml` 曾一次展示所有插件;主干现在隐藏在 bundle 之后,查看完整树意味着打开 `dsh-agent-spine-demo`。应用包的 README 承担了这份教学职责。
- **多了一层间接。**「这个演示加载了什么?」从扫描单个 YAML 变成了阅读一个包。
## 相关
- 取代 [Make the shared example base providerless](../../rejected/architecture/2026-06-20-providerless-example-base.md):一旦主干移入 `dsh-agent-spine-demo``base*.yml` 文件被删除,将 `base.yml` 重命名为无 provider 核心便不再有意义。
- 基于 [capability-seams](2026-06-13-capability-seams.md) 的接口/实现/消费方拆分——后端和展示层保持为叶子选择;主干是共享 bundle。
- 与 [Reorganize packages into a modular hierarchy](2026-06-20-package-hierarchy.md) 互补:新的 app/core 包按该层级结构归入既有分组(`core` 放可复用的主干 bundle`ui` 放应用特有的前门)。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-generic-long-running-tool-runtime.md: 9b83a4443d6d654cda75c72fba3369a16be078e2
2026-06-20-generic-long-running-tool-runtime.zh.md: c1684be4fd5c1c3c9d913e063f4ba66bb6064459

View File

@@ -0,0 +1,128 @@
# Agent Note: The background task runtime (`ctx.tasks`) and generic task control tools
Status: implemented
## Problem
Background bash originally combined two responsibilities: the bash executor ran processes and also managed task ids, ownership, incremental reads, cancellation, completion listeners, and model-facing control tools. Adding background subagents required the same lifecycle and interaction contract. Implementing that contract independently for every long-running capability would duplicate isolation, cleanup, notification, and prompt behavior while teaching the model a different collect-and-stop protocol for each producer.
The task registry, control tools, and completion notices form one harness capability. Bash and subagents should supply execution-specific hooks without owning generic task behavior.
## Decision
The `tasks/` package group owns background-task semantics:
- `@deepseek-ai/dsh-tasks` registers running work as `ctx.tasks` and owns task ids, authorization, snapshots, reads, cancellation, waiting, completion listeners, and cleanup.
- `@deepseek-ai/dsh-tool-tasks` exposes `task_output`, `task_list`, and `task_kill`, injects completion notices, and supplies the background-task system-prompt guidance.
Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into incremental output and process cancellation; `dsh-tool-subagent` adapts a child run into final output and child disposal. The execution seams remain independent of sessions and the task registry.
`TaskService` is a concrete, process-local service. TODO(task-service-backend): separate its public contract from the implementation when a second backend defines the required lifecycle; a systemd-backed runtime is one plausible driver, but this PR does not speculate about its durability, reconnect, ownership, or observation semantics.
## Runtime contract
The literal types live in the [task data-structure catalog](../../../../docs/core-data-structures/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id.
The producer hooks define three responsibilities:
- `cancel(reason?)` synchronously requests termination, is idempotent, and must cause `done` to settle.
- `done` never rejects and settles only after the producer has released the task's resources.
- Optional `readOutput()` returns the next consuming output delta. Omitting it declares a final-output task whose terminal result comes from `TaskOutcome.output`.
Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Producer-specific information such as an exit code or stop reason belongs in `detail`; the registry does not interpret it. Task kinds form a merge-extensible string union, and task ids are branded and generated as `<kind>-N`, with a counter per kind.
The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop.
Task registrations are not effects of the producer tool fiber. Reloading a tool or control-surface plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers.
## Authorization and owner lifecycle
Task ids are runtime-global and predictable, so every access is authorized by the registry. `get`, `read`, `wait`, and `kill` accept the calling `Agent`; `list` returns only tasks visible to that caller. An owned task is accessible only to the exact owning session. Unowned tasks are open to non-agent callers and die with the task service.
The snapshot stores the owner's branded `SessionId` for authorization, while lifecycle operations retain the exact live `Agent` instance. These identities serve different purposes: session equality grants access, but exact object identity selects cleanup and completion delivery. Reusing an agent or session id cannot redirect an old scope's cleanup or notices to a replacement.
The first task for an owner attaches one asynchronous effect to `owner.ctx`. Agent-scope disposal cancels that owner's live tasks, awaits their terminal records, and removes their snapshots. This effect survives producer reloads and joins the agent's existing quiescence boundary. The task service retains the effect disposer so service reload can detach callbacks from still-live agent scopes after global teardown.
For contract-compliant producers, `AgentHandle.dispose()` resolves only after owned background work has stopped. Work intended to outlive an agent must be started unowned; survival across runtime restarts requires a separate durable-job design.
## Service surface
`TaskService` provides:
- `start(spec)` for preflighted, atomic registration.
- `get(id, caller?)` and `list(caller?)` for non-consuming snapshots.
- `read(id, caller?)` for a consuming stream delta or an idempotent final result.
- `kill(id, caller?, reason?)` for cancellation.
- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting.
- `onTaskDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment.
- `attachSurface(name)` for the control-surface availability fence.
`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing.
A producer loaded without any control surface would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachSurface()` for its lifetime, and `start()` fails before producer execution when no surface is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model surfaces can attach themselves without teaching the registry tool names.
## Model-facing control surface
`dsh-tool-tasks` registers three kind-independent tools with generic ACP cards:
- `task_output(task_id, wait?, timeout_ms?)` reads output and always appends `[status: ...]`. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Reads are non-blocking unless `wait: true`, whose timeout is defaulted and capped by plugin config. A wait timeout reports the still-running status and does not stop the task.
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`, or `(no background tasks)`.
- `task_kill(task_id, reason?)` requests cancellation immediately. The optional logged reason is forwarded to the producer. Terminal tasks report their existing status; a throwing producer cancel fails the call and leaves the task running.
Stream reads share one task-scoped consuming cursor because the owning model is the intended reader. A UI or multiple independent readers need a separate non-consuming observation API; sharing this cursor would let readers consume one another's output.
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown.
## Producer opt-in
Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash` and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it.
`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached surface, the runtime fence fails before execution.
## Producer integrations
The bash seam exposes `resolve`, `run`, and `start`. `start(spec)` returns a `BashProcess` with incremental reads, cancellation, exit facts, and a non-rejecting quiescence promise. The local executor retains live handles only so its own disposal can kill and join processes. Foreground callers continue to use `resolve` and `run` directly.
For background bash, `dsh-tool-bash` registers the calling agent as owner. Its hooks map `kill()` to cancellation, `done` to a completed or killed `TaskOutcome`, and `readOutput()` to the process's bounded incremental output plus spill and sandbox notices. Generic task tools own ids, status lines, listing, waiting, and completion notices.
For background subagents, `dsh-tool-subagent` creates a task-owned `AbortController` and begins provider startup inside the task starter. Cancellation aborts the same signal before or after provider readiness. `done` awaits both the child result and child disposal, maps completed output to a final result, maps abort to `killed`, and maps other stop reasons or infrastructure failures to `failed`. Intermediate child history remains in the child session and is not exposed through `readOutput()`.
## Alternatives considered
### Per-capability control tools
Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, notification, and guidance while increasing the model's schema and protocol burden. One runtime keeps execution-specific behavior in producers without cloning the task lifecycle.
### An immediate abstract task-runtime backend
The current `TaskStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so extracting an interface before a second implementation exists would freeze the wrong boundary.
### Consumer-owned authorization or cleanup events
Consumer-owned checks invite inconsistent or missing isolation on each new surface. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook.
### Blocking output or a separate wait tool
Blocking by default would serialize the parent while background work runs. Waiting without reading would add another model call and schema without returning useful information. `task_output(wait: true)` makes blocking explicit and combines it with result delivery.
The wait uses the shared deadline primitives but not the generic tool-timeout policy. A wait timeout is a successful observation that returns `[status: running]`; the generic policy would replace it with a timeout error. No tool-call timeout controls task lifetime after a task id has been returned.
### Runtime-owned output sinks
A push sink would centralize buffering, but bash already owns bounded buffers, truncation, and spill files behind its executor seam. Pulling formatted deltas preserves that ownership. A durable backend that owns storage may justify revisiting the producer interface.
### Random ids, promotion, or lifecycle session events
Authorization, not unguessability, is the access boundary, and ids do not derive filesystem paths; sequential branded ids keep transcripts readable. Foreground-to-background promotion requires a user interaction contract the SDK does not prescribe. Starts, reads, and notices are already logged as tool and context events, so dedicated task session events would duplicate model-visible facts.
## Testing
Unit coverage pins preflight atomicity, per-kind ids, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance.
## Consequences
Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract.
Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs.

View File

@@ -0,0 +1,43 @@
# RFC: 提取通用的长时间运行工具运行时
Status: proposed
[English](2026-06-20-generic-long-running-tool-runtime.md) | 中文
## 问题
bash 能力 seam 同时支持前台命令和长时间运行的后台任务。后台支持体量不小:抽象执行器暴露 `start``get``ownerOf``list``readOutput``kill``onTaskDone`本地执行器负责跟踪任务、增量读取、owner token、进程清理和完成监听模型侧看到三个工具`bash``bash_output``bash_kill`);工具插件将完成通知注入回所属 agent智能体的会话。本地执行器用 owner token 隔离任务访问,因为可预测的全局 task id 会带来跨会话的读取/终止风险。
[工具实操手册](../../../cookbook/adding-a-tool.md)已经指出了真正的设计异味:后台 bash 本质上是寄居在单个工具内部的通用长时间运行工具基础设施。如果未来的工具也需要后台执行、轮询、终止、所有权和完成通知,这些语义不应藏在 `dsh-bash` 里。
## 提案
将长时间运行任务的语义从 bash 上移到一个与工具无关的运行时中。bash 仍然能运行后台命令,但不再拥有 task id、ownership token、轮询、取消、完成通知以及模型侧「读取/终止此任务」命令等通用概念。
该运行时应拥有:
- 稳定的 task id 和 owner token按调用方的会话/agent 做键。
- 注册一个长时间运行任务,附带增量输出的生产者和一个完成 promise。
- 通用的 read/cancel/list 操作,对所有工具使用相同的跨会话授权规则。
- 向所属会话注入完成通知。
- 针对 pending/running/completed 任务状态的展示钩子bash 只提供命令特有的标签和输出格式化。
`dsh-bash` 保留 bash 特有的执行契约:将请求解析为命令规格、运行前台命令,或启动进程并将其流/进程句柄交给通用运行时。`dsh-tool-bash` 保留模型侧的命令工具,但后续操作变为通用的长时间运行工具操作,或者 bash 向其注册的共享工具,而不是专属的 `bash_output`/`bash_kill` 管道。
## 当前 seam 消费情况
当前消费方划分清晰:`dsh-tool-bash` 使用完整的前台/后台 seam而钩子桥接层只使用前台的 `resolve``run`(带受信的 `stdin``env`)。`get``list` 仅在测试中使用;`BashTask.done` 仅在实现内部用于 dispose资源释放生产环境的完成通知使用 `onTaskDone`。提取出的运行时应暴露一个公开的完成机制,保留钩子所需的简单前台路径,并决定后台的 `timeoutMs` 是否属于 `start`。如果它拥有进程 spawn 的职责,还应集中处理目前重复的凭证清洗逻辑。
## 验收标准
- bash 特有的包package不再定义通用的任务注册表、owner-token 授权、轮询、取消或完成通知机制。
- 一个共享的长时间运行任务服务或工具层拥有这些语义,并被文档化为未来任何具备后台能力的工具的接入路径。
- bash 后台行为仍可通过共享层使用,测试证明跨会话隔离依然成立。
- ACP 和快照 fixture测试前置数据通过共享任务词汇渲染后台 bash而非通过 bash 专属的生命周期语义。
- [工具实操手册](../../../cookbook/adding-a-tool.md)将长时间运行工具指向共享运行时,而不是让每个工具自行发明任务协议。
## 风险
bash 包失去了对一个已经可用的后台任务实现的本地所有权,实现 PRPull Request可能暂时搅动模型侧的工具名称或 transcript文本记录展示。如果最终结果是留下一份后台任务契约而不是让每个未来的长时间运行工具克隆 bash 的私有协议,这种搅动是值得的。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-20-package-hierarchy.md: a06e963d118d895cc55a0f5e378bbf12678f3491
2026-06-20-package-hierarchy.zh.md: a136f15a4197645b06fa0a68564a9e0464af1cba

View File

@@ -0,0 +1,75 @@
# Agent Note: Reorganize packages into a modular hierarchy
Status: implemented
The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here.
## Problem
`packages/` was flat: 18 packages all sat at `packages/<name>/`, so a package's location said nothing about whether it was core product API, a swappable capability seam, a provider adapter, a product integration, or example/test support. The package README carried a `FIXME(package-hierarchy)` and `scripts/publint-all.ts` a `TODO(package-inventory)` flagging exactly this. Core packages, provider integrations, capability seams, example UI support, and snapshot-only replay support all looked equally foundational.
This was not just cosmetic. Because every top-level package looked like part of the same public surface, future removal was harder, and publish/lint/doc scripts had to encode intent through comments or hand-maintained static lists rather than reading it off the layout.
## Decision
Packages are grouped by modular role at a uniform `packages/<group>/<pkg>/` depth. Group directories are pure containers (no `package.json`); every package keeps its `@deepseek-ai/dsh-<pkg>` name — this is repo structure and maintenance policy, not package renaming.
```text
packages/
core/ (product API spine)
session/
system-prompt/
tools/
agent/
agent-loop/
llm/ (product — capability family)
llm/
llm-deepseek/
llm-pi-ai/
bash/ (product — capability family)
bash/
bash-local/
tool-bash/
session-persistence/ (product — capability family)
session-persistence/
session-persistence-jsonl/
session-persistence-sqlite/
ui/ (product integration)
acp/
support/ (dev/test/example infrastructure)
invariants/
ui-stdio/
llm-replay/
```
### Placement decisions
- **Same-name nesting for capability families.** A family's interface package sits at `packages/<group>/<group>/` (`llm/llm`, `bash/bash`, `session-persistence/session-persistence`), with implementations and consumers as flat siblings. There is no extra `adapters/`/`impls/` sub-tier — every package is exactly depth 2, which keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package (unique dir names make first-on-disk-wins unambiguous).
- **`session` stays in `core/`; persistence is its own family.** The session log is core product API. Its storage backends form a parallel capability family (`session-persistence/`) mirroring `llm/` and `bash/`, rather than nesting under `core/session/`.
- **`agent-loop` is in `core/`.** It is the one concrete implementation of the `agent` seam, but it ships as the harness's default product loop, so it lives with the core spine. Plugins still depend on the `agent` vocabulary, never on `agent-loop`, so the loop stays swappable.
- **`invariants` and `ui-stdio` are `support/`, not product.** `invariants` is dev-mode contract checking. `ui-stdio` was extracted from the examples for reuse and the coverage gate — it is example-coupled, so it sits in `support/` alongside `llm-replay` (the snapshot-test replay adapter). `acp` is the only `ui/` member because it is a real product surface (the ACP bridge an editor drives), structurally distinct from the readline demo helper.
### Deduplicating the package lists
The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead:
- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.)
- `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages/<group>/<pkg>`), resolving the `TODO(package-inventory)`.
- `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)).
### Guardrails added
Two doc-sync/hygiene gates keep the structure and its references honest, so the manual checks this restructure required do not have to be repeated by hand:
- `scripts/verify-package-paths.ts` flags a `packages/<path>` reference (in Markdown or a `.ts` comment/string) that does not resolve **and** names a real package in a segment — i.e. a stale path to a moved package. A path naming a package that exists nowhere (a forward-looking proposal) is left alone, so the gate applies uniformly across proposed/implemented/rejected.
- `scripts/check-workspace-constraints.ts` asserts the `packages/<group>/<pkg>` shape: group dirs carry no `package.json`, and no package sits flat at the root or nests deeper. Group names stay open — a new group may be added without editing the gate; only the depth-2 shape is fixed.
## Alternatives considered
- **A third tier (`adapters/` / `impls/` under each family)** — rejected: uniform depth 2 keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package.
- **Nesting persistence under `core/session/`** — rejected: the storage backends form a parallel capability family mirroring `llm/` and `bash/`, while the session log itself stays core product API.
- **`ui-stdio` under `ui/`** — rejected: it is example-coupled dev support, not a product surface; `acp` is the only `ui/` member because an editor actually drives it.
## Consequences
The restructure churned imports, workspace globs, doc links, build references, and package paths in one coordinated move. That churn is acceptable pre-release (per the AGENTS.md foundation-over-blast-radius stance) because it stops the flat layout from fossilizing support packages as product contracts, and it is a one-time cost: the wildcard `paths`, the glob-derived publint list, and the shape gate mean a new package needs no further structural edits.

View File

@@ -0,0 +1,75 @@
# RFC: 将包重组为模块化层级结构
Status: implemented
[English](2026-06-20-package-hierarchy.md) | 中文
## 问题
`packages/` 原先是扁平的18 个包package全部位于 `packages/<name>/`,从路径上完全看不出一个包属于核心产品 API、可替换的能力 seam、提供方适配器、产品集成还是示例/测试支撑。包的 README 带着 `FIXME(package-hierarchy)``scripts/publint-all.ts` 带着 `TODO(package-inventory)`,标记的正是这个问题。核心包、提供方集成、能力 seam、示例 UI 支撑和仅用于快照的回放支撑看起来同样基础。
这不仅仅是外观问题。由于每个顶层包看起来都属于同一个公开接口,未来移除更加困难,而 publish/lint/doc 脚本不得不通过注释或手工维护的静态列表来编码意图,而不是从布局中直接读取。
## 决策
按模块角色将包分组,统一放在 `packages/<group>/<pkg>/` 深度。分组目录是纯容器(没有 `package.json`);每个包保留其 `@deepseek-ai/dsh-<pkg>` 名称——这是仓库结构与维护策略的调整,不是包的重命名。
```text
packages/
core/ (product API spine)
session/
system-prompt/
tools/
agent/
agent-loop/
llm/ (product — capability family)
llm/
llm-deepseek/
llm-pi-ai/
bash/ (product — capability family)
bash/
bash-local/
tool-bash/
session-persistence/ (product — capability family)
session-persistence/
session-persistence-jsonl/
session-persistence-sqlite/
ui/ (product integration)
acp/
support/ (dev/test/example infrastructure)
invariants/
ui-stdio/
llm-replay/
```
### 放置决策
- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/``llm/llm``bash/bash``session-persistence/session-persistence`),实现和消费方作为扁平兄弟并列。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2这使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(唯一的目录名使 first-on-disk-wins 无歧义)。
- **`session` 留在 `core/`;持久化独立成族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族`session-persistence/`),与 `llm/``bash/` 对称,而非嵌套在 `core/session/` 下。
- **`agent-loop``core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环交付,因此与核心主干同处。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`,所以循环仍可替换。
- **`invariants``ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取出来以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试的回放适配器)一起放在 `support/` 中。`acp``ui/` 的唯一成员,因为它是真正的产品接口(编辑器驱动的 ACP 桥接),与 readline 演示辅助工具在结构上截然不同。
### 去重包列表
包列表此前在五个地方重复枚举。统一的深度 2 布局使大部分可以被推导:
- `tsconfig.base.json` 通过一条 `@deepseek-ai/dsh-*` `paths` 通配符(每个分组列一个候选)映射所有包,取代了逐包条目。根 `tsconfig.json` 复用该源映射,并携带显式 project references 以保持 package/vendor 类型检查边界完整。(这里引入了一个细节:路径候选中包含 `/*/`,朴素的正则注释剥离器会将其误认为块注释——`scripts/doc-typecheck.ts` 正是因此通过 TypeScript 解析器读取 JSONC 配置,而非手动剥离注释。)
- `scripts/publint-all.ts` 通过读取层级结构(`packages/<group>/<pkg>`)推导列表,解决了 `TODO(package-inventory)`
- `tsconfig.build.json` 的 project `references` 仍为显式列表——TypeScript project references 没有通配符形式。从 manifest元数据清单生成这些引用留作后续工作见 [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md))。
### 新增的护栏
两道 doc-sync/hygiene 门禁确保结构及其引用保持正确,使本次重组所需的手动检查无需日后重复:
- `scripts/verify-package-paths.ts` 标记 Markdown 或 `.ts` 注释/字符串中的 `packages/<path>` 引用,如果该引用无法解析**且**某个路径段命名了一个真实存在的包,即指向已移动包的陈旧路径。如果路径命名的包在任何地方都不存在(前瞻性提案),则不予标记,因此该门禁在 proposed/implemented/rejected 中统一适用。
- `scripts/check-workspace-constraints.ts` 断言 `packages/<group>/<pkg>` 形状:分组目录不带 `package.json`,且没有包扁平地位于根层或嵌套更深。分组名称保持开放——添加新分组无需修改门禁;只有深度 2 的形状是固定的。
## 曾考虑的替代方案
- **第三层(每个族下设 `adapters/`/`impls/`**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。
- **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/``bash/` 对称,而会话日志本身属于核心产品 API。
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口;`acp``ui/` 的唯一成员,因为编辑器实际驱动它。
## 后果
本次重组在一次协调的变更中搅动了 import、workspace glob、文档链接、构建引用和包路径。这种变动在发布前是可接受的依据 AGENTS.md 中「基础优先于爆炸半径」的立场),因为它阻止了扁平布局将支撑包固化为产品契约,且这是一次性成本:通配符 `paths`、glob 推导的 publint 列表和形状门禁意味着新增一个包无需额外的结构性编辑。

View File

@@ -0,0 +1,146 @@
# Agent Note: Bounded recovery for transient LLM request failures
Status: implemented
## Problem
`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank.
That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate.
The prior boundary left three narrower gaps.
- Provider failures retain only a message and usually a code. HTTP status, retry delay, and provider request id are discarded or recoverable only through provider-specific error objects, so generic recovery cannot make or explain a decision without parsing text.
- Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log.
- A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop.
The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer.
## Decision
### Preserve failure facts without embedding policy
`@deepseek-ai/dsh-llm` exports one JSON-serializable `LlmFailure` payload:
```ts ignore-check
type ProviderRequestId = Branded<'ProviderRequestId'>
interface LlmFailure {
message: string
code: string
status?: number
providerRetryAfterMs?: number
requestId?: ProviderRequestId
}
```
`code` remains the provider-neutral machine-routing taxonomy established by `HarnessError`; the new fields are observations from the provider boundary. `ProviderRequestId` is owned and constructed by `dsh-llm`, then serializes as its provider-issued string. The payload deliberately has no `retryable`, `failover`, `partialOutput`, provider, model, phase, or route id fields. Retryability belongs to policy, provider/model are already in the durable request header, and partial output is derived from the failed step's `assistant/chunk` events.
`LlmError` carries `failure: LlmFailure` and preserves `failure.code === error.code`. `FinishReasonMap.error` and `FinishReasonMap.aborted` carry the same payload instead of parallel failure shapes. An adapter-thrown `Error` keeps its exact object identity: the final-adapter scope associates the normalized facts with that object in call-local sidecar state and rethrows it unchanged; a non-`Error` throw is wrapped as today. `llmFailureOf(stream, error)` retrieves those facts alongside the existing provenance check, while an in-band finish without an error object becomes a new `LlmError`. This preserves listeners that key on error type or identity while giving all final-adapter failures, including unknown SDK exceptions, an `UNKNOWN` terminal payload.
The agent loop keeps `RequestError` as that exact error object and passes `LlmFailure` as a separate argument to `agent/request-error`; it does not mutate possibly frozen third-party errors. It also uses the payload when converting an in-band finish and when recording an unrecovered `turn/end.reason`.
Adapters extract structured facts before falling back to message inspection. They validate HTTP status, parse `Retry-After` seconds or dates into a positive finite millisecond delay, brand the provider request id when exposed, and distinguish their own timeout from the caller's abort. Provider-specific codes and messages may refine a mapping, but no recovery listener parses them.
The initial shared transient-code set is intentionally small: the adapters' existing `RATE_LIMIT` and `SERVER` mappings plus explicit `TIMEOUT` and `TRANSPORT` codes for the two missing remote-failure families. Authentication, quota, invalid request, context overflow, protocol, abort, and unknown failures keep distinct stable codes and are not transient by default. Adding a code requires adapter fixtures and a documented policy decision; it does not require expanding a second failure-class enum.
### Put retry policy on the existing failed-step seam
`@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow.
The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies.
The plugin resolves and validates this deployment configuration at load:
```ts ignore-check
interface Config {
maxTransientRetries?: number
initialDelayMs?: number
maxDelayMs?: number
jitterRatio?: number
retryableCodes?: string[]
}
```
The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the four transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets.
For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered.
The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener.
Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection.
The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative.
The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default.
### Make one layer own visible attempts
Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`.
`ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper.
### Bound stalled streams where they can be stopped
Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time.
`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer.
Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract.
### Keep attempts separate in the existing log
A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks.
If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added.
## Out of scope
- Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider.
- Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message.
- Repairing malformed tool arguments, refusals, content filters, or other semantic model output.
- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets.
- Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer.
## Alternatives considered
- **Retry inside `llm/stream` or the provider SDK** — rejected because a raw stream has no durable attempt boundary after emitting chunks, hidden SDK retries multiply budgets, and neither path can record each failed attempt consistently.
- **Add response start, interrupted, discarded, failed, and committed events to `dsh-llm`** — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry.
- **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility.
- **Put `retryable` or `failover` on `LlmFailure`** — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch.
- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe.
- **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state.
- **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code.
## Verification
- `LlmFailure` is the single serializable payload for thrown, error-finish, and aborted-finish final-adapter failures; normalization preserves stable code, status, retry delay, branded provider request id, error cause, and caller-abort versus adapter-timeout classification where available.
- An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors.
- DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text.
- Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail.
- `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets.
- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies.
- HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive.
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff.
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery.
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance.
- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus durable discarded-attempt markers in append-only ACP and stdio streams. Keyless snapshots cover scheduling, cancellation, success, and exhaustion.
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
## Consequences
- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk.
- Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text.
- Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work.
- Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition.
- Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift.
- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it.
## Related
- [Structured error taxonomy](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md) owns stable machine-routable codes and cause chaining.
- [Reconstructable requests](../../implemented/architecture/2026-07-05-reconstructable-requests.md) makes provider/model and complete request inputs durable before dispatch.
- [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination.
- [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery seam and bounded overflow retry.
- [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-21-mandatory-app-attribution-headers.md: 132c827b48693af85fde67cf9d2d71cc84e853c6
2026-06-21-mandatory-app-attribution-headers.zh.md: 9bba6d912b0b9bb33174c1ebb51a84dfdb47af0f

View File

@@ -0,0 +1,84 @@
# Agent Note: Mandatory `User-Agent` attribution for provider requests
Status: implemented
English | [中文](2026-06-21-mandatory-app-attribution-headers.zh.md)
## Problem
LLM provider requests should identify the product making them. That is useful for provider-side support, abuse investigation, compatibility debugging, and traffic analytics. Before this Agent Note the harness only partially did this: the hand-rolled DeepSeek adapter sent a hand-copied `User-Agent` constant (`packages/llm/llm-deepseek/src/adapter.ts`), while the pi-ai-backed twin sent no harness-owned headers at all (`packages/llm/llm-pi-ai/src/adapter.ts`). New adapters could therefore omit attribution silently, and a library-backed adapter could drift from the hand-rolled adapter even though [the twin-adapter Agent Note](2026-06-13-twin-llm-adapters.md) exists to keep the provider seam honest across both implementations.
The immediate prompt came from OpenRouter's [App Attribution](https://openrouter.ai/docs/app-attribution) docs. OpenRouter creates app pages and rankings from `HTTP-Referer` plus display/category headers. That is valuable, but it is not the HTTP standard for application identity. The risk is adopting OpenRouter's exact header set as if it were universal, then leaking provider-specific headers to direct DeepSeek requests, future OpenAI/Anthropic/Vertex adapters, test servers, or proxies that log unknown fields indefinitely.
## Investigation
- **OpenRouter's mechanism is provider-specific.** Their current docs say app attribution is tracked through `HTTP-Referer` (required), `X-OpenRouter-Title`, and `X-OpenRouter-Categories`; `X-Title` is only accepted for backward compatibility. Their API reference calls the headers optional and says they make the app discoverable on OpenRouter. This is a concrete OpenRouter contract, not an IETF or OpenAI-compatible API standard.
- **In agent tooling, `HTTP-Referer` is an OpenRouter-aware convention, not a general agent convention.** It is common enough that OpenRouter SDKs and OpenRouter examples expose it directly, and frameworks that target OpenRouter usually need a way to pass it through. But agent protocols such as ACP negotiate names, versions, and capabilities in their own initialize messages, while model-provider requests still need HTTP-level identity. "Accepted in the agent world" therefore means "recognized by OpenRouter integrations," not "portable across agent runtimes or providers."
- **Coding agents identify the product and version in `User-Agent`.** Public implementations vary in environment detail and provider-specific side headers, but product identity is the common contract; there is no universal exact format.
- **The standards-track general client identity header is `User-Agent`.** RFC 9110 section 10.1.5 defines `User-Agent` as the user-agent software identity, says it is used for interoperability reports and analytics, and says a user agent SHOULD send it on each request unless configured not to. This is the only standard header that directly matches "what product is making this HTTP request."
- **`Referer` is standard, but OpenRouter's `HTTP-Referer` is not the standard field.** RFC 9110 section 10.1.3 defines `Referer` as the URI from which the target URI was obtained and spends significant text on privacy restrictions. OpenRouter instead asks for `HTTP-Referer`, using it as an app URL identifier. That name and meaning are OpenRouter-specific even though it resembles the CGI environment variable form of the standard `Referer` header.
- **`From` is standard but not suitable as a mandatory default.** RFC 9110 section 10.1.2 defines `From` as an email address for the human responsible for a user agent. Robotic agents SHOULD send it so servers can contact an operator, but non-robotic agents should not send it without explicit user configuration because of privacy and security policy concerns. The harness can support an operator contact later, but must not invent one or require it globally.
- **Request-body `user` or `metadata` fields are not app attribution.** Some model APIs expose a stable end-user identifier, request metadata, labels, or project/account headers. Those are useful for abuse monitoring, internal billing, dashboards, or trace correlation, but they either identify the end user rather than the product, are provider-specific body schema, or are not guaranteed to be forwarded through OpenAI-compatible gateways. They are not a substitute for a static application identity header.
- **SDK telemetry headers identify the SDK, not the app.** Official and third-party SDKs often send library/version headers. Those help the SDK maintainer debug their client, but they do not identify the harness as the application unless the application explicitly supplies a product attribution layer.
- **pi-ai has a first-class header hook.** `@earendil-works/pi-ai`'s `StreamOptions.headers` merges caller headers last over provider defaults, so a library-backed adapter can satisfy the same wire contract as the hand-rolled one without wrapping or upstream work. The mock-server suites assert arrival on the wire for both adapters.
## Decision
Provider request attribution is mandatory at the LLM adapter boundary, using the standard `User-Agent` header only. The rule: every product LLM adapter sends a static, non-secret application identity on every provider HTTP request, and every adapter has tests proving that `User-Agent` reaches the wire (a mock server asserting received headers; for a library-backed adapter, the library's header hook feeding the same mock-server assertion).
Do **not** implement OpenRouter app attribution in this Agent Note. `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories` are OpenRouter-specific product-surface headers, not provider-neutral model-request attribution. They can be proposed later by an OpenRouter adapter or explicit OpenRouter mode, with its own privacy/product decision, tests, and docs. Until then, even requests pointed at OpenRouter send only the shared `User-Agent` attribution from this Agent Note.
The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attribution.ts`), not by individual adapters. `AppIdentity` contains only public product facts needed to build `User-Agent`, and the default `APP_IDENTITY` settles the values the proposal left open:
- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity)
- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home; a `FIXME` in `attribution.ts` blocks release until that repository actually exists
The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override seam is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field names are case-insensitive on the wire):
| Target | Mapping |
|---|---|
| All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. |
| Direct DeepSeek endpoint | `User-Agent`; do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. |
| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this Agent Note. |
| Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. |
Endpoint detection is not part of this Agent Note because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names.
## Verification
The landed contract:
- `dsh-llm` documents the mandatory `User-Agent` attribution contract for `LlmAdapter` authors (`LlmAdapter` JSDoc, package README, and the adapter-contract section of `docs/core-data-structures/llm-streaming.md`).
- A shared helper (`attributionHeaders` / `userAgent`) constructs the app identity and the standard `User-Agent` value from package metadata, so adapters do not hand-copy version constants.
- `dsh-llm-deepseek` sends the shared `User-Agent` on every request and its mock-server suite asserts the exact value.
- `dsh-llm-pi-ai` sends the same `User-Agent` through pi-ai's `StreamOptions.headers` hook and its mock-server suite asserts the exact value.
- No adapter sends OpenRouter-specific attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, `X-OpenRouter-Categories`) as part of this Agent Note.
- No app-attribution field carries secrets, local paths, session ids, prompt text, model output, user email, or per-user stable identifiers.
- The adapter READMEs state the `User-Agent` attribution policy and explicitly avoid documenting OpenRouter app attribution as implemented behavior.
## Alternatives considered
**OpenRouter app attribution now.** Rejected for this Agent Note. Sending `HTTP-Referer` plus `X-OpenRouter-Title` would satisfy OpenRouter rankings, but those headers are a provider-specific product feature, not the provider-neutral model-request attribution this Agent Note is trying to standardize. Supporting them should be an explicit OpenRouter adapter/mode decision later, not hidden inside the first shared attribution helper.
**OpenRouter headers everywhere.** Rejected. It would treat a custom OpenRouter contract as a universal standard and send fields with misleading semantics to providers that did not ask for them. It also risks using `HTTP-Referer` as a generic app URL field even though standard HTTP already has `User-Agent` for product identity and `Referer` for a different browsing-context concept.
**Only provider account/project identity.** Rejected. Organization/project headers, API keys, cloud accounts, and billing projects identify who pays or owns the request, not which application is sending traffic. They also expose no public app title/category and do not help gateways like OpenRouter build app rankings.
**End-user `user`/`metadata` fields.** Rejected for this Agent Note. Those are valuable for abuse monitoring and customer support but describe the human or tenant behind a request. App attribution must be static product identity and safe to send on every request.
**Config-only opt-in attribution.** Rejected. A default-off setting is exactly how adapters keep drifting. The policy is mandatory default attribution with overrideable public values, not optional attribution.
**Product-named token (`deepseek-harness-sdk`).** Considered for the `User-Agent` token, since the product name is DeepSeek Harness SDK. `deepseek-harness` won on continuity: it is the identity providers already see from this codebase, it matches the org/repo identity and package scope, and it keeps wire attribution stable while display copy carries the product name.
## Consequences
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise. The `FIXME` marker on the constant blocks a release from shipping with it unresolved (see `docs/development.md` marker semantics).
**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
**OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution.

View File

@@ -0,0 +1,84 @@
# RFC: 对提供方请求强制携带 `User-Agent` 归属标识
Status: implemented
[English](2026-06-21-mandatory-app-attribution-headers.md) | 中文
## 问题
LLM大语言模型提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 RFC 之前harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 RFC](2026-06-13-twin-llm-adapters.md) 的存在正是为了让两种实现在提供方 seam 上保持诚实。
直接触发因素来自 OpenRouter 的 [App Attribution](https://openrouter.ai/docs/app-attribution) 文档。OpenRouter 根据 `HTTP-Referer` 加上 display/category 头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。
## 调研
- **OpenRouter 的机制是提供方特有的。** 其当前文档说明应用归属通过 `HTTP-Referer`(必需)、`X-OpenRouter-Title``X-OpenRouter-Categories` 来追踪;`X-Title` 仅为向后兼容而接受。其 API 参考称这些头部为可选,并说它们使应用在 OpenRouter 上可被发现。这是一份具体的 OpenRouter 契约,而非 IETF 或 OpenAI 兼容 API 标准。
- **在 agent 工具生态中,`HTTP-Referer` 是一种 OpenRouter 感知的约定,而非通用 agent 约定。** 它足够常见,以至于 OpenRouter SDK 和示例直接暴露它,面向 OpenRouter 的框架通常需要一种方式来透传它。但 ACPAgent Client Protocol等 agent 协议在自己的 initialize 消息中协商名称、版本和能力,而模型提供方请求仍需 HTTP 层面的身份标识。因此「在 agent 世界中被接受」意味着「被 OpenRouter 集成所识别」,而非「可跨 agent 运行时或提供方移植」。
- **编程 agent 在 `User-Agent` 中标识产品和版本。** 公开实现在环境细节和提供方特有的附加头部上各有不同,但产品身份是共同契约;不存在通用的精确格式。
- **标准化的通用客户端身份头部是 `User-Agent`。** RFC 9110 第 10.1.5 节将 `User-Agent` 定义为用户代理软件身份,说明它用于互操作性报告和分析,并说用户代理应当在每个请求中发送它(除非被配置为不发送)。这是唯一直接对应「哪个产品在发出此 HTTP 请求」的标准头部。
- **`Referer` 是标准的,但 OpenRouter 的 `HTTP-Referer` 不是标准字段。** RFC 9110 第 10.1.3 节将 `Referer` 定义为获取目标 URI 的来源 URI并用大量篇幅讨论隐私限制。OpenRouter 则要求 `HTTP-Referer`,将其用作应用 URL 标识符。该名称和含义是 OpenRouter 特有的,尽管它形似标准 `Referer` 头部的 CGI 环境变量形式。
- **`From` 是标准的,但不适合作为强制默认值。** RFC 9110 第 10.1.2 节将 `From` 定义为负责用户代理的人的电子邮件地址。机器人代理应当发送它以便服务器联系运营者但非机器人代理出于隐私和安全策略考虑不应在未经用户显式配置的情况下发送。harness 可以后续支持运营者联系方式,但不得凭空捏造或全局强制要求。
- **请求体中的 `user``metadata` 字段不是应用归属。** 部分模型 API 暴露稳定的终端用户标识符、请求元数据、标签或项目/账户头部。这些对滥用监控、内部计费、仪表盘或链路追踪有用,但它们要么标识的是终端用户而非产品,要么是提供方特有的 body schema要么不保证能通过 OpenAI 兼容网关透传。它们不能替代静态的应用身份头部。
- **SDK 遥测头部标识的是 SDK而非应用。** 官方和第三方 SDK 常发送库/版本头部。这些帮助 SDK 维护者调试其客户端,但除非应用显式提供产品归属层,否则它们不能标识 harness 作为应用。
- **pi-ai 有一流的头部钩子。** `@earendil-works/pi-ai``StreamOptions.headers` 将调用方头部最后合并覆盖提供方默认值因此基于库的适配器无需包装或上游改动即可满足与手写适配器相同的协议格式契约。mock 服务器测试套件对两个适配器都断言头部到达了线路。
## 决策
在 LLM 适配器边界,提供方请求归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个生产 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。
本 RFC **不**实现 OpenRouter 应用归属。`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter也只发送本 RFC 定义的共享 `User-Agent` 归属。
提供方无关的身份由 `dsh-llm``packages/llm/llm/src/attribution.ts`)拥有,而非各适配器。`AppIdentity` 仅包含构建 `User-Agent` 所需的公开产品事实,默认的 `APP_IDENTITY` 确定了提案中留待决定的值:
- `User-Agent` 的产品 token`deepseek-harness`(与 RFC 之前的线路值及仓库/组织身份保持连续性)
- 版本:通过 `createRequire` 从所属包的 manifest 读取,绝不手动复制常量
- 应用 URL`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页;`attribution.ts` 中的 `FIXME` 标记在该仓库实际存在之前阻塞发布
默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖 seam 就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
线路映射(`attributionHeaders`代码中头部名称小写——HTTP 字段名在线路上不区分大小写):
| 目标 | 映射 |
|---|---|
| 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 |
| 直连 DeepSeek 端点 | `User-Agent`;除非 DeepSeek 文档化了等效契约,否则不发送 OpenRouter 特有头部。 |
| OpenRouter 端点 | 目前仅 `User-Agent`。本 RFC 下不发送 `HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`。 |
| 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 RFC 接受额外头部。不要类比复用 `HTTP-Referer`。 |
端点检测不在本 RFC 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter检测必须是显式的要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。
## 验证
已落地的契约:
- `dsh-llm``LlmAdapter` 作者文档化了强制的 `User-Agent` 归属契约(`LlmAdapter` JSDoc、包 README以及 `docs/core-data-structures/llm-streaming.md` 的适配器契约章节)。
- 共享辅助函数(`attributionHeaders` / `userAgent`)从包元数据构建应用身份和标准 `User-Agent` 值,适配器无需手动复制版本常量。
- `dsh-llm-deepseek` 在每个请求上发送共享的 `User-Agent`,其 mock 服务器套件断言精确值。
- `dsh-llm-pi-ai` 通过 pi-ai 的 `StreamOptions.headers` 钩子发送相同的 `User-Agent`,其 mock 服务器套件断言精确值。
- 本 RFC 下没有适配器发送 OpenRouter 特有的归属头部(`HTTP-Referer``X-OpenRouter-Title``X-Title``X-OpenRouter-Categories`)。
- 没有应用归属字段携带机密、本地路径、会话 id、提示词文本、模型输出、用户邮箱或逐用户的稳定标识符。
- 适配器 README 声明了 `User-Agent` 归属策略,并明确避免将 OpenRouter 应用归属记录为已实现的行为。
## 曾考虑的替代方案
**现在就实现 OpenRouter 应用归属。** 本 RFC 否决。发送 `HTTP-Referer``X-OpenRouter-Title` 可以满足 OpenRouter 排名,但这些头部是提供方特有的产品功能,不是本 RFC 试图标准化的提供方无关的模型请求归属。支持它们应当是后续显式的 OpenRouter 适配器/模式决策,而非隐藏在首个共享归属辅助函数中。
**向所有提供方发送 OpenRouter 头部。** 否决。这会把一份自定义的 OpenRouter 契约当作通用标准,并向未要求这些字段的提供方发送语义误导的头部。还有风险将 `HTTP-Referer` 当作通用应用 URL 字段使用,尽管标准 HTTP 已有 `User-Agent` 用于产品身份、`Referer` 用于不同的浏览上下文概念。
**仅使用提供方账户/项目身份。** 否决。组织/项目头部、API key、云账户和计费项目标识的是谁付费或谁拥有请求而非哪个应用在发送流量。它们也不暴露公开的应用标题/类别,无法帮助 OpenRouter 等网关构建应用排名。
**终端用户 `user`/`metadata` 字段。** 本 RFC 否决。这些对滥用监控和客户支持有价值,但描述的是请求背后的人或租户。应用归属必须是静态的产品身份,且可安全地在每个请求上发送。
**仅配置启用的归属。** 否决。默认关闭的设置正是适配器不断漂移的原因。策略是强制默认归属加可覆盖的公开值,而非可选归属。
**以产品命名的 token`deepseek-harness-sdk`)。** 曾考虑用于 `User-Agent` token因为产品名是 DeepSeek Harness SDK。`deepseek-harness` 因连续性胜出:它是提供方从本代码库已经看到的身份,与组织/仓库身份和包 scope 一致,且在展示文案承载产品名的同时保持线路归属稳定。
## 后果
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个悬空承诺。常量上的 `FIXME` 标记阻塞发布,不允许带着未解决的问题出门(见 `docs/development.md` 标记语义)。
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 契约。
**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-24-web-capability-seam.md: fdb611be9efbf29717e41ffa2db86c55258ebe2e
2026-06-24-web-capability-seam.zh.md: f5db538f0565c4fc5667dcbec0791579246d238d

View File

@@ -0,0 +1,334 @@
# Agent Note: Web capability seam - stable tools over multiple providers
Status: implemented
English | [中文](2026-06-24-web-capability-seam.zh.md)
## Problem
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized seam does not just mirror one vendor. Fetch is a separate capability: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
The model-facing surface must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract.
There is also a provider-selection question. Existing `tool-bash` and `tool-fs` can rely on Cordis `inject` because there is one backend service key. Web has two independent capabilities (`search` and `fetch`) and potentially multiple providers per capability. `inject: ['web']` proves the seam exists; it does not prove a usable search or fetch provider exists, and it does not define which provider should win when several are registered.
## Decision
Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors.
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-local`.
3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`.
Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
Search and fetch are separate tools but one web-access seam. `ctx.web` owns provider selection, abort/error vocabulary, and deployment configuration for both parallel registries. Their request schemas and provider logic remain separate; the shared service is the product boundary for reaching the web.
`dsh-tool-web` registers model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern:
- `web_search` is registered when web search is enabled for the product/app, `web_fetch` when web fetch is.
- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
## Package topology
The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and a richer selection policy (a configured provider id, or auto-select when exactly one usable provider is registered), so the `WebError` an execution throws can explain why a search or fetch capability cannot run.
The dependency direction mirrors bash and filesystem:
```text
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
consumer interface implementation
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
implementation
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
implementation
<--depends on-- @deepseek-ai/dsh-web-fetch-local
implementation
```
At runtime, provider packages register capabilities with `ctx.web`; `tool-web` registers stable tools with `ctx.tools` and executes through the seam:
```mermaid
flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
```
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages.
## `ctx.web` contract
`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
```ts
interface WebSearchProvider {
readonly id: string
available(): boolean
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
interface WebFetchProvider {
readonly id: string
available(): boolean
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
interface WebService {
registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
```
The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`.
Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
## Provider availability and selection
Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls.
`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
| Situation | Execution behavior |
|---|---|
| A configured provider id is registered and `available() === true` | runs that provider |
| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
| No provider id is configured and no provider for that kind is registered | fails with `WEB_PROVIDER_UNAVAILABLE` |
| No provider id is configured and multiple usable providers for that kind are registered | fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order |
| No provider id is configured and providers exist but none are usable | fails with `WEB_PROVIDER_UNAVAILABLE` |
The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs set explicit provider ids:
```yaml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
fetchProvider: local-http
- id: web-search-exa
name: '@deepseek-ai/dsh-web-search-exa'
- id: web-search-perplexity
name: '@deepseek-ai/dsh-web-search-perplexity'
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
- id: web-fetch-local
name: '@deepseek-ai/dsh-web-fetch-local'
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
```
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
## Search request and result schema
The `web_search` model-facing tool is small. The only model-facing argument is:
- `query`: required string.
`max_results` is NOT exposed to the model. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam.
`maxResults` flows tool → seam → provider, and the bound is enforced on the way back:
- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`.
- `ctx.web` passes the request through to the selected provider unchanged.
- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization.
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly.
```ts
interface WebSearchRequest {
readonly query: string
/** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */
readonly maxResults?: number
}
interface WebSearchResult {
readonly content?: string
readonly sources: readonly WebSearchSource[]
readonly truncated: boolean
}
interface WebSearchSource {
readonly url: string
readonly title?: string
readonly snippet?: string
readonly publishedAt?: string
}
```
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url``url`, `title``title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt``publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]``url``url`, `title``title`, `snippet``snippet` (often empty), `publishedAt``date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies.
## Fetch request and result schema
The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `local-http`. It fetches bytes from a concrete URL, applies the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).)
The seam request stays smaller than OpenCode's model-facing tool:
- `url`: required HTTP(S) URL.
The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
```ts
interface WebFetchRequest {
readonly url: string
}
interface WebFetchResult {
readonly url: string
readonly statusCode: number
readonly body: WebFetchBody
readonly truncated: boolean
}
type WebFetchBody =
| { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string }
```
`WebFetchResult.url` is the final URL after allowed redirects. The request URL is already present in `WebFetchRequest`, so there is no separate `requestedUrl`/`finalUrl` pair.
`WebFetchBody` is a closed discriminated union because body kinds require coordinated changes to the seam, provider, and tool rather than independent plugin extension. Exhaustive switches make a new kind fail compilation at every renderer until handled. Separate object arms leave room for kind-specific fields.
The provider owns safe resource retrieval: URL validation, HTTP transport, redirect policy, timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `dsh-tool-web` owns presentation: HTML-to-markdown, HTML-to-text, truncation formatting for the model, and future summaries.
The fetch provider's resource controls:
- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
- Abort signals propagate through network fetches and expensive decoding.
- Only same-origin redirects are followed automatically; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
- Requests carry an explicit product user agent rather than silently impersonating a browser.
SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets.
## Tool consumer behavior
`dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
`dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
The model-facing output is text-first because tool results are `ContentBlock[]`, but the seam outcome stays structured so UI presentation and future adapters do not have to scrape rendered text.
## Errors
`dsh-web` defines `WebError extends HarnessError` with stable codes, covering only states that callers may reasonably branch on:
- `WEB_PROVIDER_UNAVAILABLE`
- `WEB_PROVIDER_CONFIGURED_MISSING`
- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`
- `WEB_PROVIDER_AMBIGUOUS`
- `WEB_DUPLICATE_PROVIDER`
- `WEB_INVALID_URL`
- `WEB_BLOCKED_URL`
- `WEB_REDIRECT_BLOCKED`
- `WEB_FETCH_TOO_LARGE`
- `WEB_FETCH_TIMEOUT`
- `WEB_ABORTED`
- `WEB_UNSUPPORTED_CONTENT_TYPE`
- `WEB_PROVIDER_ERROR`
`WEB_DUPLICATE_PROVIDER` is thrown synchronously from `registerSearchProvider` / `registerFetchProvider` when an id is already registered for that capability kind (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); it is a registration-time programming error, not an execution outcome, but shares the `WebError` code space so callers see one taxonomy. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure in `web-fetch-local` (DNS, connection refused, TLS); there is deliberately no separate `WEB_NETWORK` code — the provider sets a descriptive message so the model and logs can tell a network failure from a provider API failure.
Tool execution lets these errors flow through `ToolRegistry.execute()`, which already converts `HarnessError` into an error tool result with structured metadata. The model gets a readable error message; hooks, tests, and UI code can route on the stable code.
## Testing
Each layer is pinned at its own seam: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-local`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
## Alternatives considered
### Let each provider register its own model-facing tool
This matches the most flexible provider-plugin systems: every provider can expose its full native schema. It is rejected for the harness because it gives provider packages ownership of model-facing names, descriptions, prompt guidance, and result formatting. Multiple search providers would produce duplicate tool names or provider-specific tool names, and the model would learn backend details instead of a stable product capability.
### Put provider dispatch directly in `dsh-tool-web`
This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema.
### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`)
Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" config surface — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately.
### Choose the first registered provider
Rejected. Registration order is not a product policy. It can change with config order, plugin loading, HMR, or refactors. Provider selection must be explicit, or automatic only when exactly one usable provider exists.
### Treat Firecrawl/Exa/Tavily/Parallel extraction as fetch
Rejected for the first version. Those providers often return extracted or summarized content rather than a concrete HTTP response. If the product needs extraction, design `web_extract` or deliberately widen the fetch seam later.
### Mirror Claude Code's `url + prompt` WebFetch shape
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
## Consequences
**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
**Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can reach sensitive network targets or exfiltrate data through URLs. Only the basic transport hygiene ships (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets.
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
## Deferred work
- SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets.
- A `pdf` `WebFetchBody` kind: the `local-http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled.
- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently.
- Permission policy integration once the deferred permission system lands.
- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly.
## Open questions
- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution?
- Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both?

View File

@@ -0,0 +1,334 @@
# RFC: Web 能力 seam——稳定的工具覆盖多个提供方
Status: implemented
[English](2026-06-24-web-capability-seam.md) | 中文
## 问题
harness 需要面向模型的 web 工具,但不能将模型契约绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 seam 并非只是镜像某一家厂商。Fetch 是另一项独立能力:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
面向模型的接口必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。
如果把搜索和 fetch 直接放进 `dsh-tool-web`面向模型的工具就要同时承担提供方选择、后端请求映射、传输策略、结果归一化、prompt 引导、展示和 schema 注册。让每个提供方注册自己的工具则有相反的问题:工具的可用性、名称、描述和参数将取决于恰好加载了哪些提供方包,提供方特有字段会泄漏到模型契约中。
还有一个提供方选择的问题。现有的 `tool-bash``tool-fs` 可以依赖 Cordis 的 `inject`因为只有一个后端服务键。Web 有两项独立能力(`search``fetch`),每项能力可能有多个提供方。`inject: ['web']` 能证明 seam 存在,但不能证明存在可用的搜索或 fetch 提供方,也无法定义多个提供方注册时谁胜出。
## 决策
Web 访问是一个一等能力 seam遵循[能力 seam RFC](../../implemented/architecture/2026-06-13-capability-seams.md)
1. `@deepseek-ai/dsh-web``packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa``@deepseek-ai/dsh-web-search-perplexity``@deepseek-ai/dsh-web-search-deepseek``@deepseek-ai/dsh-web-fetch-local`
3. `@deepseek-ai/dsh-tool-web``packages/web/tool-web`)拥有面向模型的 `web_search``web_fetch` 工具 schema、prompt 段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。
提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、prompt 引导、JSON Schema、展示的唯一所有者。
搜索和 fetch 是两个独立工具,但属于同一个 web 访问 seam。`ctx.web` 为两个并行注册表统一拥有提供方选择、abort/错误词汇和部署配置。它们的请求 schema 和提供方逻辑保持独立;共享的服务是触达 web 的产品边界。
`dsh-tool-web` 在产品启用了相应工具且 `ctx.web` seam 存在时注册面向模型的 web 工具。后端可用性是执行时关注点,而非 schema 注册时关注点:
- `web_search` 在产品/应用启用了 web 搜索时注册,`web_fetch` 在启用了 web fetch 时注册。
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR热模块替换时序纳入面向模型的契约。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE``WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
## 包拓扑
由三个包构成的接口/实现/消费方拆分沿用 bash 和 filesystem 的模式,但*接口*包更接近 LLM大语言模型 seam。`LlmService``packages/llm/llm/src/index.ts`)是一个按名称键控的提供方注册表:`registerAdapter(models, adapter)` 将适配器存入 `Map`、返回 disposer、对重复键抛出 `DUPLICATE_ADAPTER`、在解析时抛出 `NO_ADAPTER``ctx.web` 沿用该注册表形状,但有两种能力类别和更丰富的选择策略(配置的提供方 id或在恰好只有一个可用提供方注册时自动选择因此执行时抛出的 `WebError` 能解释搜索或 fetch 能力为何无法运行。
依赖方向与 bash 和 filesystem 一致:
```text
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
consumer interface implementation
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
implementation
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
implementation
<--depends on-- @deepseek-ai/dsh-web-fetch-local
implementation
```
运行时,提供方包向 `ctx.web` 注册能力;`tool-web``ctx.tools` 注册稳定的工具并通过 seam 执行:
```mermaid
flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
toolWeb -->|ctx.tools.register| webSearch["tool: web_search"]
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
```
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性契约和错误码。它不导入 tool、agent、session、LLM 或提供方包。
提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
`@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web``@deepseek-ai/dsh-tools``@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。
## `ctx.web` 契约
`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行面。注册表部分与 `LlmService` 保持接近:每种能力类别一个 `Map<id, provider>``registerSearchProvider`/`registerFetchProvider` 方法返回 disposer重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`seam 的形状:
```ts
interface WebSearchProvider {
readonly id: string
available(): boolean
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
interface WebFetchProvider {
readonly id: string
available(): boolean
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
interface WebService {
registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
```
可选的 signal 是执行控制,而非业务输入:`tool-web` 直接传递 `exec.signal`,使轮次取消、工具超时和 agent dispose资源释放能到达提供方的网络请求、流读取器和高开销解码。seam 不传递 `ToolExecution`——否则 `dsh-web` 就要依赖 `dsh-tools`
提供方 id 是稳定字符串,在各自的能力类别内唯一。注册重复的搜索提供方 id 或重复的 fetch 提供方 id 会失败,而非静默替换旧提供方。提供方注册返回 disposer沿用现有的 `ctx.tools.register()`/`ctx.systemPrompt.section()` 模式:变更包裹在 `ctx.effect()` 中,注册随贡献它的 fiber 一起拆除。
## 提供方可用性与选择
提供方可用性与能力选择是两个独立概念,但都保持最小化。提供方仅报告该具体实现是否可用,通过廉价的本地检查(如凭证是否存在、端点配置是否可解析)。提供方的 `available()` 禁止发起网络调用。
`LlmService` 完全没有状态类型:可用性通过注册表成员资格加解析时抛出来表达。`ctx.web` 遵循同样的纪律。seam 不暴露聚合的能力状态查询——`search()`/`fetch()` 在每次调用时根据配置的提供方 id、已注册的提供方和每个提供方廉价的本地 `available()` 布尔值派生选择结果,选择失败就是执行时抛出的结构化 `WebError`。需要知道某项能力能否运行的调用方通过执行并路由该错误来获知;没有任何东西作为可变服务状态存储。
该布尔值是选择的输入,而非健康系统。`tool-web` 从不直接调用提供方的 `available()`——它进入 seam 的唯一路径是 `search()`/`fetch()`——因此选择策略只有一个所有者。
选择不得依赖注册顺序。Cordis 加载顺序、配置排列和 HMR 时序不是产品语义。
| 情况 | 执行行为 |
|---|---|
| 配置的提供方 id 已注册且 `available() === true` | 运行该提供方 |
| 配置的提供方 id 未注册 | 以 `WEB_PROVIDER_CONFIGURED_MISSING` 失败 |
| 配置的提供方 id 已注册但不可用 | 以 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 失败 |
| 未配置提供方 id且该类别恰好有一个已注册且可用的提供方 | 运行该唯一提供方 |
| 未配置提供方 id且该类别无已注册提供方 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 |
| 未配置提供方 id且该类别有多个可用提供方已注册 | 以 `WEB_PROVIDER_AMBIGUOUS` 失败,而非按注册顺序选择 |
| 未配置提供方 id且有提供方存在但均不可用 | 以 `WEB_PROVIDER_UNAVAILABLE` 失败 |
「唯一提供方自动选择」规则面向测试、演示和简单部署。产品配置设置显式提供方 id
```yaml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
fetchProvider: local-http
- id: web-search-exa
name: '@deepseek-ai/dsh-web-search-exa'
- id: web-search-perplexity
name: '@deepseek-ai/dsh-web-search-perplexity'
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
- id: web-fetch-local
name: '@deepseek-ai/dsh-web-fetch-local'
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
```
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
`ctx.web.search()``ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE``WEB_PROVIDER_CONFIGURED_MISSING``WEB_PROVIDER_CONFIGURED_UNAVAILABLE``WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
## 搜索请求与结果 schema
面向模型的 `web_search` 工具很小。唯一的面向模型参数是:
- `query`:必填字符串。
`max_results` 不暴露给模型。它是 `dsh-tool-web` 层的决策:工具设定结果上限——`searchMaxResults` 插件配置,默认 `8`(与 OpenCode 的 Exa 默认值对齐),类似 `dsh-tool-fs``readLimit`——并作为 `WebSearchRequest` 上的 `maxResults` 传给 seam。将其排除在模型 schema 之外意味着模型只需提问,产品控制返回多少上下文;该字段日后可以提升为面向模型的参数而不破坏 seam。
`maxResults` 沿 tool → seam → provider 流动,上限在返回路径上强制执行:
- `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。
- `ctx.web` 将请求原样传递给选定的提供方。
- 当提供方的 API 支持结果数量控制时Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制Perplexity或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。
```ts
interface WebSearchRequest {
readonly query: string
/** Upper bound on returned sources; the seam truncates to it. Omitted = no bound. `dsh-tool-web` always sets it. */
readonly maxResults?: number
}
interface WebSearchResult {
readonly content?: string
readonly sources: readonly WebSearchSource[]
readonly truncated: boolean
}
interface WebSearchSource {
readonly url: string
readonly title?: string
readonly snippet?: string
readonly publishedAt?: string
}
```
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用面。source 必有 URLtitle、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填Perplexity 风格的引用可能只提供 URL强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它Perplexity 在搜索结果上返回 `date`因此它是真实的提供方数据而非派生值seam 以字符串形式传递,日期解析留给消费方。
Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource``url``url``title``title``snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet被丢弃`publishedAt``publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url``url``title``title``snippet``snippet`(常为空)、`publishedAt``date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。
## Fetch 请求与结果 schema
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `local-http`。它从具体 URL 获取字节,应用下述基本传输卫生措施(仅 http/https、拒绝 URL 中的凭证、字节/时间上限、跨源重定向阻断),解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。(完整的 SSRF/私有网络阻断推迟——见[推迟工作](#deferred-work)。)
seam 请求比 OpenCode 的面向模型工具更小:
- `url`:必填 HTTP(S) URL。
seam 请求刻意不包含逐调用超时、`format``prompt` 或提供方特有的提取控制。取消通过直接的可选执行信号实现fetch 提供方拥有一个部署配置的超时兜底。`format` 是对已获取资源的展示决策;`prompt` 是更高层的 LLM 摘要指令Firecrawl、Exa、Tavily 或 Parallel 等提取 API 可能不暴露具体的 HTTP 响应。如果产品日后需要提供方支撑的页面提取,那是一个独立的 `web_extract` 能力或对本 seam 的刻意扩展——提取语义绝不通过将每个 HTTP 字段设为可选来偷渡进 `web_fetch`
HTTP 状态码是已获取资源状态的一部分,不自动构成工具失败。成功的网络获取一个 `404``500` 响应会返回带有状态码和有界解码正文(当内容类型受支持时)的 `WebFetchResult``WebError` 用于无法安全获取或表示资源的失败:无效或被阻断的 URL、重定向策略违规、超时、abort、响应过大、不支持的内容类型、提供方失败或网络失败。
```ts
interface WebFetchRequest {
readonly url: string
}
interface WebFetchResult {
readonly url: string
readonly statusCode: number
readonly body: WebFetchBody
readonly truncated: boolean
}
type WebFetchBody =
| { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string }
```
`WebFetchResult.url` 是允许的重定向之后的最终 URL。请求 URL 已在 `WebFetchRequest` 中,因此没有单独的 `requestedUrl`/`finalUrl` 对。
`WebFetchBody` 是封闭的可辨识联合类型,因为正文类别需要 seam、提供方和工具三方协调变更而非独立的插件扩展。穷举 switch 使新类别在每个渲染器处编译失败,直到被处理。独立的对象分支为类别特有字段留出空间。
提供方负责安全的资源获取URL 校验、HTTP 传输、重定向策略、超时、abort 传播、字节上限、字符集解码、内容类型分类和二进制拒绝。`dsh-tool-web` 负责展示HTML 转 Markdown、HTML 转纯文本、面向模型的截断格式化,以及未来的摘要。
fetch 提供方的资源控制:
- 仅接受 `http:``https:` URL拒绝 URL 中的凭证。
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
- Abort 信号传播到网络获取和高开销解码。
- 仅自动跟随同源重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用,从而触发新的提供方/权限决策。Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 请求携带显式的产品 User-Agent而非静默伪装浏览器。
SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他非公开目的地,通过先 DNS 解析再验证 IP 来防御 rebinding并在重定向的每一跳重新验证**推迟**——见[推迟工作](#deferred-work)。在其落地之前,`web_fetch` 是一个 SSRF 原语,不得在能触达敏感内部网络目标的部署中启用。
## 工具消费方行为
`dsh-tool-web` 拥有两个 `ToolDefinition``web_search``web_fetch`。它拥有面向模型的 JSON Schema、snake_case 参数名、prompt 段落、结果渲染为 `ContentBlock[]``presentCall``presentResult`
`dsh-tool-web` 禁止枚举提供方或直接调用提供方的 `available()`。它进入 seam 的唯一路径是 `ctx.web.search()`/`ctx.web.fetch()`。这将提供方选择保持在单一层;否则工具包可能判定某个提供方可用,而执行时解析出不同的状态。
工具注册是最小化的稳定同步:插件启动时,`dsh-tool-web``Config``search?: boolean``fetch?: boolean`,均默认 `true`)启用或禁用每个 web 工具;已启用的工具通过基于 effect 的注册表以 fiber 作用域的 disposer 注册;任何工具都不会仅因其选定的提供方缺失、不可用或存在歧义而被 disposedispose `tool-web` fiber 时自动拆除其注册。
提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。
prompt 引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——prompt 和工具结果告诉模型用 Markdown 链接引用相关 URL。
面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。
## 错误
`dsh-web` 定义 `WebError extends HarnessError`,带有稳定的错误码,仅覆盖调用方可能合理分支的状态:
- `WEB_PROVIDER_UNAVAILABLE`
- `WEB_PROVIDER_CONFIGURED_MISSING`
- `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`
- `WEB_PROVIDER_AMBIGUOUS`
- `WEB_DUPLICATE_PROVIDER`
- `WEB_INVALID_URL`
- `WEB_BLOCKED_URL`
- `WEB_REDIRECT_BLOCKED`
- `WEB_FETCH_TOO_LARGE`
- `WEB_FETCH_TIMEOUT`
- `WEB_ABORTED`
- `WEB_UNSUPPORTED_CONTENT_TYPE`
- `WEB_PROVIDER_ERROR`
`WEB_DUPLICATE_PROVIDER``registerSearchProvider`/`registerFetchProvider` 发现该能力类别中已有相同 id 时同步抛出(类似 `LlmService``DUPLICATE_ADAPTER`);它是注册时的编程错误而非执行结果,但共享 `WebError` 码空间,使调用方看到统一的分类体系。`WEB_PROVIDER_ERROR` 是提供方自身失败通过 seam 浮出的兜底码,包括 `web-fetch-local` 中的网络/传输失败DNS、连接拒绝、TLS刻意不设单独的 `WEB_NETWORK` 码——提供方设置描述性消息,使模型和日志能区分网络失败与提供方 API 失败。
工具执行让这些错误流经 `ToolRegistry.execute()`,后者已将 `HarnessError` 转换为带结构化元数据的错误工具结果。模型得到可读的错误消息;钩子、测试和 UI 代码可以根据稳定的错误码路由。
## 测试
每一层在自己的 seam 处固定:`dsh-web` 中的注册/选择/截断/abort 契约与 `WebError` 码;每个提供方基于录制的 fixture测试前置数据的请求/响应映射Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-local` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事后分析 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`
## 曾考虑的替代方案
### 让每个提供方注册自己的面向模型工具
这与最灵活的提供方插件系统一致:每个提供方可以暴露其完整的原生 schema。在 harness 中被否决因为它将面向模型的名称、描述、prompt 引导和结果格式化的所有权交给了提供方包。多个搜索提供方会产生重复的工具名或提供方特有的工具名,模型将学到后端细节而非稳定的产品能力。
### 将提供方调度直接放在 `dsh-tool-web` 中
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。
### 将搜索和 fetch 拆为两个 seam`dsh-search`、`dsh-fetch`
很有吸引力,因为两半不共享请求 schema 和业务逻辑,各自能干净地映射到 bash/fs 的三包模板上,且 `WebService` 上的 `Search`/`Fetch` 方法对重复也会消失。否决,因为共享的机制——提供方 id 注册表、不依赖注册顺序的选择策略、abort 传播、`WebError` 分类体系,以及面向产品的「这个 harness 如何触达 web」配置面——是真实存在的否则会在两个几乎相同的 seam 之间重复。一个 `ctx.web` 中间层给产品一个统一的注入和配置对象,给提供方选择一个唯一的所有者。代价是并行的 `searchX`/`fetchX` 方法对,这是有意接受的。
### 选择第一个注册的提供方
否决。注册顺序不是产品策略。它可能随配置顺序、插件加载、HMR 或重构而变化。提供方选择必须是显式的,或仅在恰好只有一个可用提供方时自动选择。
### 将 Firecrawl/Exa/Tavily/Parallel 提取视为 fetch
在第一版中否决。这些提供方通常返回提取或摘要后的内容,而非具体的 HTTP 响应。如果产品需要提取,日后设计 `web_extract` 或刻意扩展 fetch seam。
### 镜像 Claude Code 的 `url + prompt` WebFetch 形状
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`
## 后果
**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title``snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
**稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当醒目地浮出结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,使用户不会在模型调用工具后才发现配置问题。
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 能触达敏感网络目标或通过 URL 外泄数据。仅交付基本传输卫生措施(仅 http/https、拒绝凭证、字节/时间上限、跨源重定向阻断SSRF/私有网络阻断推迟(见[推迟工作](#deferred-work)),因此在其落地之前,`web_fetch` 不得在能触达内部目标的环境中启用。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated``tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
## 推迟工作
- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP防御 DNS rebinding/TOCTOU、跨重定向的每跳重新验证以及 IPv6 边缘处理私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断OpenCode 做前缀检查后直接 fetchClaude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的 prompt因此没有可复制的实现且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前`web_fetch` 只能在无法触达敏感内部目标的部署中启用。
- `pdf` `WebFetchBody` 类别:`local-http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`
- 推迟的权限系统落地后的权限策略集成。
- `query``maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
## 开放问题
- 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING``WEB_PROVIDER_CONFIGURED_UNAVAILABLE``WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出?
- 推迟的权限系统落地后,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有?

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-26-file-context-as-event-gate.md: ac9a8bcbc5bfda2b35cae4993497273608abf35b
2026-06-26-file-context-as-event-gate.zh.md: eda637f9adf8346779c70d59575863bf7e1162c9

View File

@@ -0,0 +1,171 @@
# Agent Note: Make `dsh-fs-policy` an event-gate plugin, not a method interface
Status: implemented
English | [中文](2026-06-26-file-context-as-event-gate.zh.md)
## Problem
[The split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`.
This couples three things that should be separable:
1. **What the tool does** — resolve a path, read a window, write/edit a file. This is the tool's job and needs only `ctx.fs`.
2. **The freshness/observation policy** — "edit requires a prior read", "write/edit must be based on the version you read". This is the `dsh-fs-policy` plugin's job.
3. **The recording of observed state** — a side effect that should never block the tool from functioning.
Because the tool calls `fileContext` methods, removing the policy layer is a breaking change rather than a graceful loss of an *add-on*. The policy is load-bearing for the tool to even run, not an opt-in tightening.
## Decision
Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service.
```text
tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs;
emits fs policy events; renders results
policy dsh-fs-policy plugin: listens to fs/write-intent +
fs/edit-intent (single-slot waterfall) and fs/observed
(emit) events; adds observed-state + freshness.
provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version
guard is OPTIONAL; owns the fs policy event vocabulary
provider dsh-fs-local local implementation of ctx.fs
```
The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance.
`dsh-tool-fs` no longer injects `fileContext`. It injects `fs` and `tools`/`systemPrompt`.
## The policy is enforced by provider CAS, not by `dsh-fs-policy` stat
`dsh-fs-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness:
- "Have you read this file?" is the one thing `dsh-fs-policy` decides locally — a `WeakMap` lookup, no I/O. No record ⇒ `FS_NOT_OBSERVED`.
- "Is the version you read still current?" is decided **inside `ctx.fs.editText`/`writeText`**, in the same atomic lock that performs the read-match-rename. `dsh-fs-policy` passes `vObserved` as the expectation; the provider raises `FS_STALE_VERSION` if the file has moved on.
This is deliberate. If `dsh-fs-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-policy` only chooses the basis (`vObserved`) and gates on prior observation.
## Provider contract change: the version guard is optional
For the bare provider to be unconstrained, the version guard on its two mutations becomes **optional** — present ⇒ guarded, absent ⇒ unconditional:
```ts ignore-check
// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
// undefined → unconditionally create-or-overwrite (bare default)
// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged]
// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged]
// editText: expected becomes optional (was the required { version: FsVersion }).
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
// undefined → unconditionally replace literal text in the current content (bare default);
// a missing target still reports FS_STALE_VERSION
// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior)
```
The `FsWriteIntent` union itself does not change — the third "unconditional" state is expressed by *omitting* `expected`, so both mutations share one symmetric shape (`expected?`: omit = no guard, present = guarded). This keeps full backward compatibility for the guarded paths `dsh-fs-policy` uses; only the previously-impossible "no guard" case is new, and it is the bare-provider default. The mutation still runs inside the backend's per-target lock either way, so an unconditional write/edit is still atomic (no torn files); "unconditional" drops the *version* precondition, not the atomicity. `editText` reports a missing target as `FS_STALE_VERSION` on both guarded and unguarded paths, preserving one edit failure code for "the target cannot be edited at this moment".
## Event vocabulary (owned by `dsh-fs`)
The events live in `@deepseek-ai/dsh-fs`, not in `dsh-fs-policy`. This is forced by the decoupling contract: `dsh-tool-fs` is the emitter, so it must reference the event types, and it must keep compiling even though `dsh-fs-policy` no longer provides a method service. `dsh-fs` is the package both `dsh-tool-fs` and `dsh-fs-policy` already depend on, so it is the only home that lets the emitter and the policy listener share a vocabulary without the emitter depending on the policy plugin.
These events carry existing `dsh-fs` vocabulary (`FsTarget`, `FsVersion`, `FsWriteIntent`) plus an opaque actor — not model-facing concepts (no line windows, numbered lines, or rendered footers leak down).
**The two `fs/*` decision events are single-slot, first-wins waterfalls.** `dsh-fs-policy` returns without calling `next()`, so it owns the slot in the default deployment; a listener registered earlier or with `prepend` would replace that policy. Permission, audit, and sandbox concerns remain on the composable `tools/execute` waterfall.
The actor is typed `object` in `dsh-fs` — a pure opaque carrier the provider seam never reads or narrows. The owner-derivation (`actor.agent?.session`) and the `{ agent?: { session? } }` structural shape stay entirely inside `dsh-fs-policy`, which narrows the `object` actor to that shape in its listeners. `dsh-fs` owns the event names and the fs vocabulary; it does NOT own the policy layer's runtime owner structure.
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
interface Events {
/**
* Single-slot decision: produce the write expectation for the next
* ctx.fs.writeText. The default returns undefined (unconditional create-or-
* overwrite — the bare provider). The policy listener returns createIfAbsent
* (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
* The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
*/
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
/**
* Single-slot decision: produce the optional version guard for the next
* ctx.fs.editText. The default returns undefined (unconditional edit of the
* current content — the bare provider; no stat). The policy listener returns
* { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or
* has not observed the target. Does NOT call next(): one decision. @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. Fire-and-forget (plain emit). Listeners MUST be
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
* write); the tool does not guard the emit, so a throwing listener surfaces as
* the tool's isError result. No listener ⇒ nothing recorded.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
}
```
The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (like `agent/request`, which the loop dispatches with no `this`), not service-bound waterfalls (like `llm/stream`). The dispatcher is the `dsh-tool-fs` plugin, which is not a service.
## Tool contract (`dsh-tool-fs`)
The tool keeps its model-facing schemas (`read`/`write`/`edit`, byte-for-byte unchanged) and prompt sections. The prompt guidance stays policy-first because a deployment loading the fs tools is expected to also load `dsh-fs-policy`: the model is still told to read before overwriting or editing, and any wording that says the "backend" requires that should be corrected to say the fs-policy plugin requires it. The bare-provider fallback does not change the prompt stance.
`dsh-tool-fs` gains the executor responsibilities relocated from the old `fileContext` method service, including **read rendering** (`read-render.ts`: `buildWindow` + `formatReadOutput`, `READ_MAX_BYTES`, `READ_MAX_LINE_LENGTH`, `FileReadOutcome`/`FileTextLine`, plus `STREAM_MIN_SIZE` in `read.ts`), which is the tool's rendering detail now that the tool owns the read. Those read-rendering types and helpers move into `dsh-tool-fs`; the policy plugin must not remain a type dependency for the tool.
`dsh-tool-fs` is a single root plugin that registers all three tools (`read`/`write`/`edit`), mirroring `dsh-tool-bash`. It injects `fs` (plus `tools`/`systemPrompt`), never `fileContext`. (The original proposal also exposed each tool as a `/read`/`/write`/`/edit` subpath plugin for focused deployments; that was dropped on implementation — no consumer needed a single-tool deployment, and the subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries. The per-tool registration helpers (`applyReadTool`/`applyWriteTool`/`applyEditTool`) remain internal modules the root plugin composes.)
`stat` budget is minimized by letting the waterfall produce the expectation lazily — the bare default returns `undefined` (no guard) and never stats:
- **read** — one `stat` (type + size routing + version), then `readText`/`streamText`, then `buildWindow`, then an `emit('fs/observed', target, info.version, exec)`. The post-read confirming `stat` from the old `fileContext.read` is dropped; a writer racing between the routing stat and the read can at worst make a *later* guarded edit spuriously `FS_STALE_VERSION` (fail-closed: the model re-reads, never writes against the wrong version, since `editText` re-checks in its lock).
- **write** — `expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`, then `ctx.fs.writeText(target, content, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** with or without `dsh-fs-policy`.
- **edit** — `expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`, then `ctx.fs.editText(target, edit, expectation)`, then an `emit('fs/observed', target, outcome.version, exec)`. **Zero stat in the tool** in both cases: the bare default is `undefined` (unconditional edit), so the tool never stats to manufacture a basis. If the target is absent, the provider reports `FS_STALE_VERSION` even on the unguarded path.
The tool passes `exec` (the tool-execution context) as the `actor` argument on every dispatch, so `dsh-fs-policy` can derive its observed-state owner. The tool does not know whether the policy plugin is present: it always provides the bare default behavior in the `next` thunk, and `dsh-fs-policy` short-circuits the thunk before it runs in the default deployment.
**`fs/observed` fires after a successful operation.** Its listeners must be synchronous, non-throwing recorders; the tool does not guard the plain emit, so a throwing listener would report failure after a mutation already succeeded. Async or fallible observation needs a separate event contract.
## Policy plugin contract (`dsh-fs-policy`)
`dsh-fs-policy` is a plugin, not a service. It does not register `ctx.fileContext`, has no public method surface, and exposes no `read`/`write`/`edit`/`resolve` methods. It attaches three listeners via `ctx.on()` registrations (each returning a disposer for HMR). It keeps the observed-state `WeakMap<owner, Map<targetKey, { version }>>` and the structural owner derivation (narrowing the event's opaque `object` actor to its own `{ agent?: { session? } }` shape), but does not inject `fs` — every handler operates only on its own `WeakMap`, never on `ctx.fs`.
- `fs/write-intent` listener: `prior = getObserved(owner, key)`; return `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`. It does NOT call `next()`: it fully owns the single decision slot.
- `fs/edit-intent` listener: `prior = getObserved(owner, key)`; if no `owner` or no `prior`, throw `FS_NOT_OBSERVED`; else return `{ version: prior.version }`. Also does not call `next()`.
- `fs/observed` listener: `record(owner, key, version)`.
An observed-state entry is the **prior-observation record**: a successful `read`, `write`, OR `edit` all emit `fs/observed` and record `{ version }`, so the entry's presence means "this owner has observed this target at this version", not narrowly "has read it". This is what lets a create-then-edit or edit-then-edit sequence work without an intervening re-read: the mutation refreshes the recorded version to its own result, so the next edit's basis is the version it just produced. `FS_NOT_OBSERVED` rejects only an edit with NO prior observation of any kind. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event seam. That is what removes the method coupling from `dsh-tool-fs`.
## Bare-provider behavior (no `dsh-fs-policy`)
This is not the intended deployment stance — a config loading the fs tools is expected to also load `dsh-fs-policy`. It is the unconstrained provider floor that exists once the tool is no longer coupled to a policy method service. With `dsh-fs-policy` absent, every `fs/*` waterfall falls through to its `undefined` default and `fs/observed` has no listener:
- **read** is identical (it never needed policy; it only emits a now-unheard `fs/observed`).
- **write** unconditionally creates-or-overwrites: `expected` is `undefined`, so `writeText` writes whether or not the file exists and whatever its current version. No read-first requirement, no version check.
- **edit** unconditionally replaces literal text in the file's current content: `expected` is `undefined`, so `editText` matches and rewrites without a version guard or a read-first requirement (`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` still apply — those are about the literal match, not freshness). A missing target still reports `FS_STALE_VERSION`, matching the guarded edit path's "cannot edit this target now" code.
Both mutations are still atomic (the backend's per-target lock is unconditional). What is simply *absent*, not lost, is the policy `dsh-fs-policy` would add: observed-state, read-before-edit, and version-guarded write/edit. Loading `dsh-fs-policy` layers those constraints on by having its listeners return guarded `expected` values instead of `undefined`; nothing in the bare provider changes.
## Supersedes
This amends — does not reverse — [the split-fs-seam Agent Note](../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 Agent Note'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 Agent Note](../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.
- **Policy-side version checking** (`dsh-fs-policy` stats and compares in its waterfall handler) — rejected for the TOCTOU gap between that check and the tool's actual write; the provider's mutation critical section is the only race-free place, so the policy only chooses the CAS basis and gates on prior observation.
- **Per-tool `/read`/`/write`/`/edit` subpath plugins** — dropped on implementation: no consumer needed a single-tool deployment, and subpath publishing forced bespoke `tsdown`/`tsconfig`/`files`/workspace-constraint handling no sibling tool package carries; the per-tool registration helpers remain internal modules the root plugin composes.
## Consequences
- **Event indirection over a method call.** A waterfall + emit is less direct than `await ctx.fileContext.edit(...)`. The payoff is removing the tool-to-policy method dependency while keeping the default policy plugin; the cost is one more event vocabulary to learn. Mitigated by keeping the three events narrow and documenting the default-thunk semantics on each.
- **Policy events in the storage seam.** `dsh-fs` gains two version-decision events plus a recording event though it is "just storage". This is the price of decoupling (the emitter cannot depend on the policy plugin). The events carry only `dsh-fs` vocabulary plus an opaque `object` actor and no model-facing concepts, so the seam stays free of line-window/observation policy types and of the agent/session owner structure.
- **Single policy occupant, first-wins by convention.** The `fs/write-intent`/`fs/edit-intent` slots hold exactly one decider; the first-registered (or `prepend`ed) listener wins and the rest are short-circuited. `dsh-fs-policy` owning the slot is a deployment convention, not an event-enforced invariant — a second decider registered first would bypass it. This is acceptable because a second fs-version-policy decider is a misconfiguration, not a feature. If a future need for *layered* fs version policy appears, it is a new Agent Note (a composable value-passing seam), not a silent second listener on these events. Layered permission/audit/sandbox interception already has its home on `tools/execute`.
- **Dropping the post-read confirming stat** makes a follow-up *guarded* edit occasionally fail-closed (`FS_STALE_VERSION` → re-read) under a read/write race. This is a UX nicety lost, never a correctness hole; the provider lock still prevents wrong-version writes.
- **The bare provider does no read-before-write/edit and no version check.** A deployment without `dsh-fs-policy` lets the model overwrite or edit any existing file unconditionally. This is the deliberate meaning of keeping the tool independent of a policy service: the safety disciplines live in the `dsh-fs-policy` plugin. A deployment that omits it is opting into an unconstrained filesystem on purpose; that is not the intended stance for a config that ships the fs tools.

View File

@@ -0,0 +1,171 @@
# RFC: 将 `dsh-fs-policy` 改为事件门控插件,而非方法接口
Status: implemented
[English](2026-06-26-file-context-as-event-gate.md) | 中文
## 问题
[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext``dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。
这把三件本应可分离的事情耦合在了一起:
1. **工具做什么**——解析路径、读取窗口、写入/编辑文件。这是工具的职责,只需要 `ctx.fs`
2. **新鲜度/观测策略**——"编辑前必须先读"、"写入/编辑必须基于你读到的版本"。这是 `dsh-fs-policy` 插件的职责。
3. **观测状态的记录**——一个副作用,永远不应阻止工具正常运行。
由于工具调用的是 `fileContext` 方法,移除策略层就是一个破坏性变更,而非优雅地失去一个*附加*能力。策略层对工具的运行是承重性的,而非可选的收紧。
## 决策
反转控制流。**`dsh-tool-fs` 成为执行器,直接调用 `ctx.fs`****`dsh-fs-policy` 成为门控 + 记录插件**,通过事件参与,从不通过工具调用的方法,也不注册 `ctx.fileContext` 服务。
```text
tool dsh-tool-fs executor: resolves, reads windows, writes/edits via ctx.fs;
emits fs policy events; renders results
policy dsh-fs-policy plugin: listens to fs/write-intent +
fs/edit-intent (single-slot waterfall) and fs/observed
(emit) events; adds observed-state + freshness.
provider seam dsh-fs ctx.fs: text IO + ATOMIC mutation primitives whose version
guard is OPTIONAL; owns the fs policy event vocabulary
provider dsh-fs-local local implementation of ctx.fs
```
该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O`dsh-fs-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent 配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。
`dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs``tools`/`systemPrompt`
## 策略由提供方 CAS 强制执行,而非 `dsh-fs-policy` 的 stat
`dsh-fs-policy` 强制执行"你必须基于你读到的版本来写入/编辑"**自身从不调用 `stat` 或比较版本**。它将观测到的版本作为 CAS 基准提供,让提供方的 mutation 临界区检测陈旧性:
- "你读过这个文件吗?"是 `dsh-fs-policy` 在本地决定的唯一事项——一次 `WeakMap` 查找,无 I/O。无记录 ⇒ `FS_NOT_OBSERVED`
- "你读到的版本是否仍为最新?"由 **`ctx.fs.editText`/`writeText` 内部**决定,在执行 read-match-rename 的同一个原子锁中完成。`dsh-fs-policy``vObserved` 作为期望值传入;如果文件已变更,提供方抛出 `FS_STALE_VERSION`
这是有意为之的。如果 `dsh-fs-policy` 在其 waterfall瀑布式事件处理器中 stat 并比较版本,该检查与工具实际写入之间会存在 TOCTOU 间隙——文件可能在此期间变化,因此该检查只是一个虚假保证,提供方的锁无论如何都要兜底。将版本检查放在提供方的临界区中既无竞态又无额外 `stat`。所以 `dsh-fs-policy` **不做**任何文件系统 I/O"必须基于最近一次读取"的保证由 CAS *实现*`dsh-fs-policy` 只负责选择基准(`vObserved`)并对先前观测进行门控。
## 提供方契约变更:版本守卫变为可选
为使裸提供方不受约束,其两个 mutation 上的版本守卫变为**可选**——传入则守卫,省略则无条件执行:
```ts ignore-check
// writeText: expected is now optional. The FsWriteIntent union is UNCHANGED.
writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
// undefined → unconditionally create-or-overwrite (bare default)
// createIfAbsent → create only, reject an existing file (dsh-fs-policy, unobserved) [unchanged]
// replaceIfVersion → overwrite only at the observed version, else FS_STALE_VERSION [unchanged]
// editText: expected becomes optional (was the required { version: FsVersion }).
editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
// undefined → unconditionally replace literal text in the current content (bare default);
// a missing target still reports FS_STALE_VERSION
// { version } → edit only at that version, else FS_STALE_VERSION (the current behavior)
```
`FsWriteIntent` 联合类型本身不变——第三种"无条件"状态通过*省略* `expected` 来表达,因此两个 mutation 共享同一种对称形状(`expected?`:省略 = 无守卫,传入 = 有守卫)。这对 `dsh-fs-policy` 使用的有守卫路径保持完全向后兼容;只有之前不可能出现的"无守卫"情况是新增的且它是裸提供方的默认行为。无论哪种情况mutation 仍在后端的 per-target 锁内运行,因此无条件写入/编辑仍是原子的(不会产生撕裂文件);"无条件"去掉的是*版本*前置条件,而非原子性。`editText` 在有守卫和无守卫路径上都将缺失目标报告为 `FS_STALE_VERSION`,保持一个统一的编辑失败码表示"此刻无法编辑该目标"。
## 事件词汇(由 `dsh-fs` 拥有)
事件定义在 `@deepseek-ai/dsh-fs` 中,而非 `dsh-fs-policy` 中。这是解耦契约所迫:`dsh-tool-fs` 是发射方,因此它必须引用事件类型,且即使 `dsh-fs-policy` 不再提供方法服务,它也必须能编译通过。`dsh-fs` 是 `dsh-tool-fs` 和 `dsh-fs-policy` 都已依赖的包,因此它是唯一能让发射方和策略监听方共享词汇而不让发射方依赖策略插件的归属地。
这些事件携带既有的 `dsh-fs` 词汇(`FsTarget`、`FsVersion`、`FsWriteIntent`)加一个不透明的 actor——不携带面向模型的概念行窗口、行号或渲染后的页脚不会泄漏到此层
**两个 `fs/*` 决策事件是单槽、先到先得的 waterfall。** `dsh-fs-policy` 不调用 `next()` 直接返回,因此在默认部署中它占据该槽位;更早注册或使用 `prepend` 的监听器会替代该策略。权限、审计和沙箱关注点仍留在可组合的 `tools/execute` waterfall 上。
actor 在 `dsh-fs` 中类型为 `object`——一个纯粹的不透明载体,提供方 seam 从不读取或收窄它。owner 的推导(`actor.agent?.session`)和 `{ agent?: { session? } }` 结构形状完全留在 `dsh-fs-policy` 内部,由其在监听器中将 `object` actor 收窄为该形状。`dsh-fs` 拥有事件名和 fs 词汇;它不拥有策略层的运行时 owner 结构。
```ts
import type { FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs'
interface Events {
/**
* Single-slot decision: produce the write expectation for the next
* ctx.fs.writeText. The default returns undefined (unconditional create-or-
* overwrite — the bare provider). The policy listener returns createIfAbsent
* (unobserved) or { kind: 'replaceIfVersion', version: vObserved } (observed).
* The listener does NOT call next(): one decision, not a composable chain. @mode waterfall
*/
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
/**
* Single-slot decision: produce the optional version guard for the next
* ctx.fs.editText. The default returns undefined (unconditional edit of the
* current content — the bare provider; no stat). The policy listener returns
* { version: vObserved }, or throws FS_NOT_OBSERVED if the actor is unset or
* has not observed the target. Does NOT call next(): one decision. @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. Fire-and-forget (plain emit). Listeners MUST be
* synchronous, side-effect-only recorders (`dsh-fs-policy`'s is a WeakMap
* write); the tool does not guard the emit, so a throwing listener surfaces as
* the tool's isError result. No listener ⇒ nothing recorded.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
}
```
`fs/*` 决策事件是**由工具分发的无绑定 waterfall**(类似 `agent/request`,由循环分发且无 `this`),而非服务绑定的 waterfall如 `llm/stream`)。分发者是 `dsh-tool-fs` 插件,它不是一个服务。
## 工具契约(`dsh-tool-fs`
工具保留其面向模型的 schema`read`/`write`/`edit`,逐字节不变)和 prompt 段落。prompt 引导仍以策略优先,因为加载 fs 工具的部署预期也会加载 `dsh-fs-policy`:模型仍被告知在覆写或编辑前先读取,任何声称"后端"要求如此的措辞应修正为 fs-policy 插件要求如此。裸提供方回退不改变 prompt 立场。
`dsh-tool-fs` 获得从旧 `fileContext` 方法服务迁移来的执行器职责,包括**读取渲染**`read-render.ts``buildWindow` + `formatReadOutput`、`READ_MAX_BYTES`、`READ_MAX_LINE_LENGTH`、`FileReadOutcome`/`FileTextLine`,以及 `read.ts` 中的 `STREAM_MIN_SIZE`),这些现在是工具的渲染细节,因为读取已由工具拥有。这些读取渲染类型和辅助函数移入 `dsh-tool-fs`;策略插件不得继续作为工具的类型依赖。
`dsh-tool-fs` 是一个注册全部三个工具(`read`/`write`/`edit`)的单一根插件,与 `dsh-tool-bash` 相同。它注入 `fs`(加 `tools`/`systemPrompt`),从不注入 `fileContext`。(最初的提案还将每个工具作为 `/read`/`/write`/`/edit` 子路径插件暴露,供聚焦部署使用;实现时被放弃——没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理。每工具的注册辅助函数(`applyReadTool`/`applyWriteTool`/`applyEditTool`)仍作为根插件组合的内部模块保留。)
通过让 waterfall 惰性产出期望值来最小化 `stat` 预算——裸默认返回 `undefined`(无守卫),从不 stat
- **read**——一次 `stat`(类型 + 大小路由 + 版本),然后 `readText`/`streamText`,然后 `buildWindow`,然后 `emit('fs/observed', target, info.version, exec)`。旧 `fileContext.read` 中读后确认的 `stat` 被移除;在路由 stat 和读取之间竞争的写入者最多只能使*后续*有守卫的编辑误报 `FS_STALE_VERSION`(快速失败:模型重新读取,从不基于错误版本写入,因为 `editText` 在其锁内重新检查)。
- **write**——`expectation = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)`,然后 `ctx.fs.writeText(target, content, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。无论是否有 `dsh-fs-policy`**工具内零 stat**。
- **edit**——`expectation = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)`,然后 `ctx.fs.editText(target, edit, expectation)`,然后 `emit('fs/observed', target, outcome.version, exec)`。两种情况下**工具内零 stat**:裸默认为 `undefined`(无条件编辑),因此工具从不 stat 来制造基准。如果目标不存在,提供方即使在无守卫路径上也报告 `FS_STALE_VERSION`。
工具在每次分发时将 `exec`(工具执行上下文)作为 `actor` 参数传入,以便 `dsh-fs-policy` 推导其观测状态的 owner。工具不知道策略插件是否存在它始终在 `next` thunk 中提供裸默认行为,而 `dsh-fs-policy` 在默认部署中会在 thunk 运行前短路它。
**`fs/observed` 在操作成功后触发。** 其监听器必须是同步、不抛异常的记录器;工具不对 plain emit 做保护,因此抛异常的监听器会在 mutation 已成功后报告失败。异步或可失败的观测需要另一份事件契约。
## 策略插件契约(`dsh-fs-policy`
`dsh-fs-policy` 是插件,不是服务。它不注册 `ctx.fileContext`,没有公开方法面,不暴露 `read`/`write`/`edit`/`resolve` 方法。它通过 `ctx.on()` 注册三个监听器(每个返回一个 disposer 用于 HMR。它维护观测状态 `WeakMap<owner, Map<targetKey, { version }>>`,以及结构化的 owner 推导(将事件中不透明的 `object` actor 收窄为自己的 `{ agent?: { session? } }` 形状),但不注入 `fs`——每个处理器只操作自己的 `WeakMap`,从不操作 `ctx.fs`。
- `fs/write-intent` 监听器:`prior = getObserved(owner, key)`;返回 `prior ? { kind: 'replaceIfVersion', version: prior.version } : { kind: 'createIfAbsent' }`。它不调用 `next()`:完全占据单一决策槽位。
- `fs/edit-intent` 监听器:`prior = getObserved(owner, key)`;如果无 `owner` 或无 `prior`,抛出 `FS_NOT_OBSERVED`;否则返回 `{ version: prior.version }`。同样不调用 `next()`。
- `fs/observed` 监听器:`record(owner, key, version)`。
一条观测状态条目是**先前观测记录**:成功的 `read`、`write` 或 `edit` 都会 emit `fs/observed` 并记录 `{ version }`,因此条目的存在意味着"此 owner 在此版本观测过此目标",而非狭义的"已读取过"。这使得 create-then-edit 或 edit-then-edit 序列无需中间重新读取即可工作mutation 将记录的版本刷新为自身的结果,因此下一次编辑的基准就是它刚产出的版本。`FS_NOT_OBSERVED` 只拒绝完全没有任何先前观测的编辑。owner 从 `{ agent?: { session? } }` 结构化推导dispose 时丢弃所有状态HMR 安全)。
`dsh-fs-policy` 现在是一个纯策略/记录插件,没有服务面——它只通过事件 seam 影响外界。这正是移除 `dsh-tool-fs` 方法耦合的关键。
## 裸提供方行为(无 `dsh-fs-policy`
这不是预期的部署姿态——加载 fs 工具的配置预期也会加载 `dsh-fs-policy`。它是工具不再耦合于策略方法服务后所存在的无约束提供方下限。当 `dsh-fs-policy` 不存在时,每个 `fs/*` waterfall 落入其 `undefined` 默认值,`fs/observed` 无监听器:
- **read** 行为不变(它从不需要策略;只是 emit 了一个现在无人监听的 `fs/observed`)。
- **write** 无条件 create-or-overwrite`expected` 为 `undefined`,因此 `writeText` 无论文件是否存在、无论当前版本如何都直接写入。无先读要求,无版本检查。
- **edit** 无条件替换文件当前内容中的字面文本:`expected` 为 `undefined`,因此 `editText` 无版本守卫、无先读要求地匹配并重写(`FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` 仍适用——它们关乎字面匹配,而非新鲜度)。缺失目标仍报告 `FS_STALE_VERSION`,与有守卫编辑路径的"此刻无法编辑该目标"错误码一致。
两个 mutation 仍是原子的(后端的 per-target 锁是无条件的)。仅仅是*不存在*(而非丢失)的是 `dsh-fs-policy` 本会叠加的策略:观测状态、先读后编辑和版本守卫的写入/编辑。加载 `dsh-fs-policy` 后,其监听器返回有守卫的 `expected` 值而非 `undefined`,从而叠加这些约束;裸提供方本身无需任何变更。
## 取代关系
本 RFC 修正——而非推翻——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方契约和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**强制性方法服务变为插件拥有的事件门控fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。split-fs-seam RFC 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。
## 验证
测试固定了两条路径:无 `dsh-fs-policy` 时,根工具插件对 `dsh-fs-local` 启动read、create、overwrite 和未读 edit 均成功;有策略时,未读 edit 返回 `FS_NOT_OBSERVED`,未读 overwrite 被 `createIfAbsent` 门控。策略决定后,后注册的 intent 监听器不会被触达。陈旧编辑通过提供方 CAS 失败,而策略不执行 `stat`;工具预算在两条路径上保持 read 一次 `stat`write 或 edit 均为零次。面向模型的 schema 逐字节不变,因此快照不变。
## 曾考虑的替代方案
- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[split-fs-seam RFC](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。
- **策略侧版本检查**`dsh-fs-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。
- **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。
## 后果
- **事件间接层取代方法调用。** 一次 waterfall + emit 不如 `await ctx.fileContext.edit(...)` 直接。收益是移除了工具到策略的方法依赖,同时保留默认策略插件;代价是多一套事件词汇需要学习。通过保持三个事件的窄小范围并在每个事件上记录 default-thunk 语义来缓解。
- **策略事件位于存储 seam 中。** `dsh-fs` 增加了两个版本决策事件和一个记录事件,尽管它"只是存储"。这是解耦的代价(发射方不能依赖策略插件)。这些事件只携带 `dsh-fs` 词汇加一个不透明的 `object` actor不携带面向模型的概念因此 seam 不沾染行窗口/观测策略类型,也不沾染 agent/session owner 结构。
- **单一策略占位者,按约定先到先得。** `fs/write-intent`/`fs/edit-intent` 槽位恰好容纳一个决策者;先注册(或 `prepend`)的监听器获胜,其余被短路。`dsh-fs-policy` 占据该槽位是部署约定,而非事件系统强制的不变式——一个先注册的第二决策者会绕过它。这是可接受的,因为第二个 fs 版本策略决策者是配置错误,而非功能。如果未来出现*分层* fs 版本策略的需求,那是一个新 RFC可组合的值传递 seam而非在这些事件上静默添加第二个监听器。分层的权限/审计/沙箱拦截已有其归属:`tools/execute`。
- **移除读后确认 stat** 使后续*有守卫*的编辑在 read/write 竞争下偶尔快速失败(`FS_STALE_VERSION` → 重新读取)。这是丢失的 UX 便利,绝非正确性漏洞;提供方锁仍阻止基于错误版本的写入。
- **裸提供方不做先读后写/编辑,也不做版本检查。** 没有 `dsh-fs-policy` 的部署允许模型无条件覆写或编辑任何已有文件。这正是保持工具独立于策略服务的有意含义:安全纪律存在于 `dsh-fs-policy` 插件中。省略它的部署是有意选择无约束的文件系统;对于发布 fs 工具的配置而言,这不是预期的姿态。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 712c17532bc1e6b95479aec5b544bc7e296952ec
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: db50f08dae30aa0c43e741a4f27f948f712954e0

View File

@@ -0,0 +1,33 @@
# Agent Note: stdin + extra env on the bash seam
Status: implemented
English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md)
## Problem
The hooks subsystem runs external hook commands the way Claude Code and Codex do: a hook is a shell command that receives its event payload as **JSON on stdin** and reads context from a handful of **environment variables** (`CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, `PLUGIN_ROOT`, …). The harness already has a perfectly good command runner behind the `ctx.bash` capability seam ([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)), with process-group kills, output truncation/spill, and a credential scrub. Reusing it for hook execution means a hook bridge does not re-implement subprocess plumbing — but the seam had no way to write stdin or set extra env. This Agent Note adds those two inputs.
`stdin` and `env` do not create a new model capability because ordinary shell syntax already supplies both. Ambient credentials are protected by `dsh-bash-local`'s child-environment scrub, not by hiding these seam fields; model tool arguments are static JSON and do not expand shell variables. The fields therefore serve trusted in-process callers, such as hook bridges, that need to pass structured input and `CLAUDE_*` variables without embedding them in model-visible shell text. See [defensive-patterns.md](../../../../docs/defensive-patterns.md) for the ambient-environment rule.
## Decision
Add `stdin?: string` and `env?: Record<string, string>` to **both** `BashExecRequest` (the model-/plugin-facing request) and `BashExecSpec` (the resolved spec `run`/`start` act on), and thread them through `dsh-bash-local`: `resolve()` carries them verbatim, `run()`/`start()` pass them to `runBash`, which writes the bytes to the child's stdin and merges the extra env.
Three deliberate choices:
1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them.
2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)``ENV_OVERRIDES` → ordinary `env``dshEnv`.
3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`.
`dsh-bash-local` creates a stdin pipe only when bytes are supplied; otherwise fd 0 remains `/dev/null`, preserving prior behavior. It writes the bytes and closes the pipe. `EPIPE` from a child that exits without reading is ignored because command exit and output determine the result.
## Alternatives considered
**Configurable ambient-secret scrub.** Rejected as speculative. Trusted callers can explicitly provide required values after the scrub without weakening the default ambient protection.
## Consequences
Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/core-data-structures/bash.md).

View File

@@ -0,0 +1,33 @@
# RFC: 在 bash seam 上支持 stdin 与额外 env
Status: implemented
[English](2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 中文
## 问题
钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**`CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT``PLUGIN_ROOT`……读取上下文。harness 已经在 `ctx.bash` 能力 seam 后面有一个完善的命令执行器([dsh-bash](../../../../packages/bash/bash) → [dsh-bash-local](../../../../packages/bash/bash-local)),具备进程组终止、输出截断/溢出处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程管道——但该 seam 此前无法写入 stdin 或设置额外 env。本 RFC 添加这两个输入。
`stdin``env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 seam 字段;模型工具参数是静态 JSON不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../defensive-patterns.md)。
## 决策
`BashExecRequest`(模型/插件侧请求)和 `BashExecSpec``run`/`start` 所作用的已解析 spec上**同时**添加 `stdin?: string``env?: Record<string, string>`,并在 `dsh-bash-local` 中贯穿它们:`resolve()` 原样传递,`run()`/`start()` 将其传给 `runBash`,后者把字节写入子进程的 stdin 并合并额外 env。
三个有意为之的选择:
1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。
2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目总是胜出**——即使键名与凭证同形。这是正确的,因为擦除的职责很窄:阻止 harness 的*环境* `process.env` 凭证泄漏到被 spawn 的命令中。调用方显式设置一个变量时,它命名的是自己已持有的值(而非环境中的秘密),因此擦除不构成对它的约束。`childEnv(extra?)``scrub(process.env)``ENV_OVERRIDES`(对模型友好的 `TERM=dumb` 等)→ `extra` 的顺序分层,后者优先。
3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK普通 optional而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」这是安全的常规情况所有模型驱动的调用都如此。因此它们保持普通 optional`signal` 一致。
`dsh-bash-local` 仅在有字节需要写入时才创建 stdin 管道;否则 fd 0 仍为 `/dev/null`,保持先前行为。它写入字节后关闭管道。子进程未读取即退出时产生的 `EPIPE` 被忽略,因为命令退出码和输出决定结果。
## 曾考虑的替代方案
**可配置的环境秘密擦除。** 否决,属于推测性需求。受信调用方可以在擦除之后显式提供所需值,无需削弱默认的环境保护。
## 后果
钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量保留其进程组终止、截断和溢出行为。模型接口面不变bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../core-data-structures/bash.md)。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-06-30-event-domain-semantics.md: a9d33a7bfb549e1ec2102c23af78f91d9ae4b904
2026-06-30-event-domain-semantics.zh.md: dc7390e3d2e1cda3469fa77389d0d6ad39b7a713

View File

@@ -0,0 +1,39 @@
# Agent Note: Event-domain semantics — session is the fact log, agent is the live surface
Status: implemented
English | [中文](2026-06-30-event-domain-semantics.zh.md)
## Problem
The harness extends the agent loop through a Cordis event taxonomy (see [the microkernel event-taxonomy Agent Note](2026-06-11-microkernel-event-taxonomy.md)). As that taxonomy grew, the line between the three event domains blurred:
- `session/*` carries the durable, event-sourced log (`SessionEventMap`).
- `agent/*` carries live runtime signals that hand a plugin the `Agent` handle.
- `tools/*` carries the tool registry + execution seam.
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 vocabulary is the foundation for interception decisions, the durable `hook/*` log, and the Claude Code and Codex bridges.
## Decision
**Three domains, one job each, with a single boundary rule.**
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path.
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`).
- **`tools/*` — the tool registry + execution seam.**
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit).
## Consequences
- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. `Session.append` owns post-commit observer containment, so a throwing boundary observer cannot change the turn outcome or starve later consumers; an acceptance or internal validation failure still escapes before the boundary enters the log.
- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together.
- The loop marks the step open (`stepOpen = true`) only after `append('step/start')` returns. Internal dispatch validation runs before the log push and may reject without opening a step; post-commit `session/event` observer failures are contained inside `Session.append`. The marker therefore represents exactly the committed boundary that owes a later `step/end`.
- The full realization of this is [the simplification Agent Note "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that Agent Note's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`.
- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events.
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->

View File

@@ -0,0 +1,39 @@
# RFC: 事件域语义——session 是事实日志agent 是运行时表面
Status: implemented
[English](2026-06-30-event-domain-semantics.md) | 中文
## 问题
harness 通过 Cordis 事件分类体系扩展 agent loop智能体循环见[微内核事件分类体系 RFC](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊:
- `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。
- `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。
- `tools/*` 承载工具注册表与执行 seam。
两个问题促使我们固定语义。第一,若干轮次/步骤边界同时作为持久的 `SessionEvent``turn/start``turn/end``step/start``step/end`)和镜像的 `agent/*` emit`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`)存在。消费方对同一事实有两个真源,每次生命周期变更都必须同时更新两处。第二,即将到来的 Hooks 子系统需要一个连贯且有文档的订阅表面——插件作者(以及基于其上构建的 Claude Code / Codex 钩子桥接)必须在不阅读循环代码的情况下知道应该监听 session 事件还是 agent 事件,以及原因。
这套词汇是拦截决策、持久的 `hook/*` 日志,以及 Claude Code 和 Codex 桥接的基础。
## 决策
**三个域,各司其职,以一条边界规则统一。**
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON无活对象。每次追加触发一次 `session/event` emit加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript文本记录想渲染或响应已发生事件的消费方在此订阅因此实时渲染与 `session/load` 回放共享同一路径。
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall瀑布式事件`agent/request``agent/step-result``agent/turn-continuation`)可变更或否决;瞬态 emit`agent/status``agent/error``agent/created`/`agent/disposed``agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的 session 事件,从 `session/event` 读取token 流(`assistant/chunk`)和中途 steering中途引导`steering/message`)同理。
- **`tools/*`——工具注册表与执行 seam。**
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于 session 日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`ACP 桥接从 `session/event``turn/end``agent/status` 结算;唯一的 turn 镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL已迁移为从 `session/event` 渲染边界,通过 `agent/created`→id 映射恢复简短的 agent 标签。step 镜像先被移除它们完全没有消费方turn 镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append无需配对 emit
## 后果
- 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end``closeTurn` 仅追加 `turn/end``Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;接受或内部校验失败仍会在边界进入日志之前逃逸。
- 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` session 事件——它们固定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的 turn 边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。
- 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`。内部分发校验在日志推入之前运行可能在不打开步骤的情况下拒绝post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。
- 完整实现见[简化 RFC「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 RFC 范围内,由其后续 RFC [移除 `agent/steering` 镜像 emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的 `steering/message`
- Cordis 事件目录(`docs/cordis-catalog/events.md`)重新生成以移除镜像事件。
<!-- rfc-format: alternatives-not-recorded (pre-format RFC) -->

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-fs-per-session-cwd.md: 2513294d6bc991ee39155bbf20869829cca1a188
2026-07-02-fs-per-session-cwd.zh.md: 2c3957113642fe2bf7b3a4f2b6186246b32aff3f

View File

@@ -0,0 +1,37 @@
# Agent Note: Resolve filesystem paths against the caller's session cwd
Status: implemented
## Problem
The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd Agent Note work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical.
A valid absolute cwd can itself have two apparent parents: when it contains `symlink/..`, filesystem lookup follows the symlink before applying `..`, while `path.resolve()` erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory.
An ordinary symlink cwd exposes the same distinction when the requested relative path contains `..`: a process traverses from the symlink's physical target, while `path.resolve(cwd, path)` traverses from its lexical spelling. Reads would therefore select a different file than bash or a sandboxed mutation for the same model-supplied path.
## Decision
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
- `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth.
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec, requestedPath)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default.
## Alternatives considered
### Why the caller supplies the cwd (not the provider)
The provider seam must not depend on `dsh-agent` / `dsh-session` — it is a text-storage backend that a sandboxed or remote implementation also satisfies, and those have no notion of an "agent session". The tool already receives the `ToolExecution` (`exec`), which carries the agent, so the tool is the right place to project `exec → cwd` and hand the provider a plain string. This is the "explicit > implicit at package seams" convention: the base directory arrives as an explicit argument the provider acts on, not smuggled in by having the provider reach into a session it should not know about. It also matches `dsh-tool-bash` one-to-one, so the two model-facing file surfaces resolve paths identically.
The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` returns `undefined` rather than `process.cwd()` when there is no session, so the tool never manufactures a base the provider would otherwise choose.
## Consequences
- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it.
- A session cwd containing `symlink/..`, or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant.
- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets.
- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional.
- The single-session stdio demo is unaffected: it supplies no session cwd (its agent's session has no `cwd`), so resolution falls back to `config.cwd = process.cwd()`, which is the workspace.

View File

@@ -0,0 +1,34 @@
# RFC: 相对文件系统路径按调用方的会话 cwd 解析
Status: implemented
[English](2026-07-02-fs-per-session-cwd.md) | 中文
## 问题
ACPAgent Client Protocol桥接层为每个会话提供独立的工作区`session/new` 将编辑器的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent智能体`session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的 per-session cwd RFC 工作与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程N 个工作区。
文件系统解析使用的是插件加载时的 cwd而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
## 决策
将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash``workdir` 的处理方式完全一致。**调用方**(即工具)提供 cwd提供方不读取会话或 agent。
- `FileSystem.resolve` 扩展为 `resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>``opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。采用 options 对象(而非位置参数 `cwd?`)为将来的解析提示留出空间,无需再次变更签名。
- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)``config.cwd` 仍作为调用方未提供 cwd 时的默认值(非 ACP无会话场景以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)。
- `dsh-tool-fs``read`/`write`/`edit` 通过共享的 `sessionCwd(exec)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd并传给 `resolve`。非 agent无 header 的调用方得到 `undefined`,后端因此应用其默认值。
## 曾考虑的替代方案
### 为何由调用方(而非提供方)提供 cwd
提供方 seam 不得依赖 `dsh-agent``dsh-session`——它是一个文本存储后端沙箱或远程实现同样满足该接口而这些实现没有「agent 会话」的概念。工具已经接收了 `ToolExecution``exec`),其中携带 agent因此工具是将 `exec → cwd` 投影并向提供方传递一个纯字符串的正确位置。这遵循「包package边界处显式优于隐式」的约定基准目录作为显式参数传入提供方据此行动而非让提供方越界去读取它不应知晓的会话。这也与 `dsh-tool-bash` 一一对应,使两个面向模型的文件操作接口以相同方式解析路径。
默认值只存在于一个地方——提供方的 `config.cwd``sessionCwd` 在没有会话时返回 `undefined` 而非 `process.cwd()`,因此工具永远不会自行制造一个提供方本应自行选择的基准目录。
## 后果
- 在 ACP 演示中fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。
- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath因此 observed-state 键控与符号链接标识不受影响——正确的 per-session cwd 产生与 bash 目标相同的 key。
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
- 单会话 stdio 演示不受影响:它不提供会话 cwd其 agent 的会话没有 `cwd`),因此解析回退到 `config.cwd = process.cwd()`,即工作区本身。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-result-time-applied-hunk-diffs.md: fd02951f21319be2933bf3ffaddeb96bc2a6819c
2026-07-02-result-time-applied-hunk-diffs.zh.md: 4ed9a29bb023343cab28b8114453d17408bc429a

View File

@@ -0,0 +1,61 @@
# Agent Note: Result-time applied-hunk diffs for file mutations
Status: implemented
English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md)
## Problem
The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string``new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair.
Driving `claude-agent-acp`'s own ACP bridge shows what a full editor diff looks like: after the mutation applies, it emits a SECOND `tool_call_update` whose diff is the **applied hunk with ±3 context lines** (and one hunk per changed site for `replace_all`), reconstructed from the tool's `structuredPatch`. That result-time hunk is what makes Zed show the change *in place* in the file rather than as a floating snippet. Our tools stopped at the call-time snippet; the completed result carried only the plain "updated successfully" text, no diff.
The obstacle is a seam boundary: `presentResult(args, result)` is a **pure function of `args` + the model-facing `result` (`{content, isError}`)** — it runs on live streaming AND on session-log replay, so it must be replay-deterministic and cannot do I/O. It never sees the file's before/after content, and `FsEditOutcome`/`FsWriteOutcome` carried only a replacement count + version, not the text. So there was no way to compute — or even carry — an applied hunk to the presenter.
## Decision
Add a **persisted, tool-private presentation channel** so a tool's `execute` can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff.
### 1. A `meta` channel on the tool result (core)
`ToolDefinition.execute` may now return either its model-facing `ContentBlock[]` (unchanged, the common case) OR `{ content: ContentBlock[]; meta?: unknown }`:
```ts ignore-check
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
```
`meta` is tool-owned `unknown` that the core persists without interpretation. `Session.append` rejects non-JSON values, and replay passes the stored payload back to `presentResult`; presentations therefore reproduce without I/O or recomputation. Runtime validation avoids adding a shared serializable-value dependency to the tools core.
This is the general shape ("a tool attaches durable result presentation"), not an fs-specific one — any tool can use it.
### 2. The tool computes the hunk; the backend returns before/after (fs)
Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**:
- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam.
- `dsh-tool-fs` stores contextual hunks in `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
### 3. The bridge renders a `diff` result card
`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result diff **supersedes** the call-time snippet (and keeps the model-facing result text from clobbering it) — the two-update sequence (call snippet, then result diff) matches `claude-agent-acp` exactly.
## Alternatives considered
**Hand-rolling or vendoring the diff algorithm.** Contextual hunks have established edge cases, so `dsh-tool-fs` uses the typed [`diff`](https://www.npmjs.com/package/diff) package and normalizes `structuredPatch` output in one module. The repository's vendoring policy applies to its framework source, not every leaf utility.
## Consequences
`tool/result` events may now carry a tool-private `meta` payload — part of the on-disk vocabulary, runtime-gated to JSON by `Session.append` — and any tool can attach durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (`TODO(overwrite-diff-bound)`), and `dsh-tool-fs` carries a small, well-known runtime dependency.
## Non-goals
- **Live incremental diff streaming.** The hunk is computed once, after the mutation completes; there is no per-keystroke diff.
- **Diffing a binary/non-UTF-8 overwrite.** `before` is `null` for such a file (it has no text diff basis); the write still succeeds and the result renders a whole-file diff (`oldText: null`) rather than a contextual hunk.
- **Rename/move diffs.** Only content diffs of a single resolved path.
- **Bounding the overwrite diff basis.** An overwrite reads the whole prior file into memory to compute the contextual hunk (on top of the new content already held), so a very large text overwrite allocates both texts for a UI-only diff. A future refinement can bound the pre-read and fall back to a whole-file / no contextual diff above a size threshold; tracked as `TODO(overwrite-diff-bound)` at the read site.
## Related
- Completes the one remaining representation difference named as a non-goal in [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) — that Agent Note's Non-goals section is updated to record that applied-hunk diffs shipped here.
- Builds on the [filesystem capability seam](2026-06-17-filesystem-capability-seam.md) (the before/after are storage facts the backend returns) and [event-sourced sessions](2026-06-11-event-sourced-sessions.md) (the `meta` payload persists on the `tool/result` event, so replay reproduces the card).
- The `meta` channel is deliberately generic: a future tool (a structured search, a data-table result) can attach its own durable result presentation without another core change.

View File

@@ -0,0 +1,61 @@
# RFC: 结果时刻的 applied-hunk diff 用于文件变更
Status: implemented
[English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文
## 问题
[tagged render-intent union](2026-07-02-tool-render-intent-union.md) 为 `dsh-tool-fs` 的 write/edit 在调用时刻提供了 `card:'diff'`纯粹从工具参数推导write ⇒ `{oldText:null, newText:content}`整个新文件edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff但这是一个**无上下文**的 diff裸的 `old_string``new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
在对接 `claude-agent-acp` 自身的 ACPAgent Client Protocol bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**`replace_all` 的每个变更位置各一个 hunk由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本 "updated successfully",没有 diff。
障碍在于一个 seam 边界:`presentResult(args, result)`**`args` + 面向模型的 `result``{content, isError}`)的纯函数**——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容`FsEditOutcome`/`FsWriteOutcome` 只携带替换计数和版本号没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。
## 决策
添加一个**持久化的、工具私有的展示通道**,使工具的 `execute` 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。
### 1. 工具结果上的 `meta` 通道core
`ToolDefinition.execute` 现在可以返回其面向模型的 `ContentBlock[]`(不变,常见情况)或 `{ content: ContentBlock[]; meta?: unknown }`
```ts ignore-check
type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
```
`meta` 是工具自有的 `unknown`core 持久化但不解释。`Session.append` 拒绝非 JSON 值,回放时将存储的载荷回传给 `presentResult`;因此展示无需 I/O 或重新计算即可复现。运行时校验避免了向 tools core 添加共享的 serializable-value 依赖。
这是通用形态("工具附加持久化的结果展示"),而非 fs 特有的——任何工具都可以使用。
### 2. 工具计算 hunk后端返回 before/afterfs
按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**
- `dsh-fs` 将 `FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }``before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。
- `dsh-tool-fs` 将上下文 hunk 存入 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。
### 3. Bridge 渲染 `diff` 结果卡片
`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}` 的 `ToolCallContent` 块与调用侧分支对称。ACP 的 `tool_call_update.content` 在编辑器中替换调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff与 `claude-agent-acp` 完全一致。
## 曾考虑的替代方案
**手写或 vendor diff 算法。** 上下文 hunk 有已知的边界情况,因此 `dsh-tool-fs` 使用带类型的 [`diff`](https://www.npmjs.com/package/diff) 包,并在一个模块中规范化 `structuredPatch` 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具库。
## 后果
`tool/result` 事件现在可以携带工具私有的 `meta` 载荷——属于磁盘格式词汇的一部分,由 `Session.append` 在运行时限制为 JSON——任何工具都可以附加持久化的结果展示而无需再改 core。diff 卡片在会话重载和快照回放时免费复现:它从日志中读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk`TODO(overwrite-diff-bound)`),且 `dsh-tool-fs` 引入了一个小型、知名的运行时依赖。
## 非目标
- **实时增量 diff 流式输出。** hunk 在变更完成后一次性计算;没有逐键 diff。
- **对二进制/非 UTF-8 覆写做 diff。** 此类文件的 `before` 为 `null`(没有文本 diff 基础);写入仍然成功,结果渲染整文件 diff`oldText: null`)而非上下文 hunk。
- **重命名/移动 diff。** 仅限单个已解析路径的内容 diff。
- **限制覆写 diff 基础的大小。** 覆写操作将整个旧文件读入内存以计算上下文 hunk加上已持有的新内容因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。未来的改进可以设定预读上限,超过阈值时回退到整文件/无上下文 diff在读取位置以 `TODO(overwrite-diff-bound)` 跟踪。
## 相关
- 补全了 [Tagged render-intent union](2026-07-02-tool-render-intent-union.md) 中作为非目标列出的最后一项表示差异——该 RFC 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。
- 基于[文件系统 capability seam](2026-06-17-filesystem-capability-seam.md)before/after 是后端返回的存储事实)和[事件溯源会话](2026-06-11-event-sourced-sessions.md)`meta` 载荷持久化在 `tool/result` 事件上,因此回放可复现卡片)。
- `meta` 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-02-tool-render-intent-union.md: 6b828c32fb79f43c0f974f2c9f032e11430de5b4
2026-07-02-tool-render-intent-union.zh.md: 8c44178ca3e8986579e7ed696aec2f900705e9ad

View File

@@ -0,0 +1,82 @@
# Agent Note: Tagged render-intent union for tool-call presentation
Status: implemented
English | [中文](2026-07-02-tool-render-intent-union.zh.md)
## Problem
A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy:
- The call-side and result-side `terminal` fields overlap, and the bridge reconciles a `content` block AND a `terminal` block AND `rawInput` per call, stitching them together with ad-hoc conditionals.
- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense.
- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff.
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot replay path).
## Decision
Replace the optional-field bag with a **`card`-tagged discriminated union**. A tool declares one render intent per call/result; the bridge switches on the tag.
```ts ignore-check
type FileLocation = { path: string; line?: number }
type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file
// presentCall → ToolCallView
type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] }
interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string }
interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] }
// presentResult → ToolResultView
type ToolResultView = GenericResultView | TerminalResultView
interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] }
interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string }
```
`card` is **required** on every variant — a real discriminant, not an optional default. The bridge does `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`. The union is **closed** (per the [switch-exhaustiveness convention](../../../../AGENTS.md)): a fourth render intent (a table, a chart) needs new bridge code to render it anyway, so a plugin-added variant that the bridge silently drops would be worse than a compile error. Adding a variant breaks compilation at the bridge switch — exactly the signal we want.
### Why a tagged union beats the field-bag
- **Invalid states become unrepresentable.** A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these.
- **The bridge switches instead of stitching.** One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented.
- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'`; the bridge emits an ACP `{type:'diff', path, oldText, newText}` `ToolCallContent` (already in the SDK's `ToolCallContent` union, previously unused by the bridge). This is the affordance the redesign unlocks.
### Producer mapping
- `dsh-tool-fs` read → `generic` (`kind:'read'`, a follow-along `location`); write → `diff` (`oldText:null`); edit → `diff` (`oldText:old_string || null`, `newText:new_string ?? ''`). This mirrors `claude-agent-acp`'s `toolInfoFromToolUse` Read/Write/Edit arms field-for-field.
- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` → `generic`. The generic `task_*` controls own their own generic cards.
- `dsh-tool-todo` → `generic`.
### Terminal fallback ownership
`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte.
### Purity preserved
`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string``new_string`.
## Relative-path display titles
`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names.
## Alternatives considered
- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
- **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch.
- **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.
## Consequences
A new render intent is a compile-breaking change at the bridge switch — deliberately: rendering code must exist before a card kind does. Invalid card/field combinations are now unrepresentable, and the bash fallback derivation lives in the bridge, so a tool returns one structured shape. The bar for a fourth card (a table, a chart) is writing its bridge arm in the same change.
## Non-goals
- **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering Agent Note's own deferred follow-ups, untouched here.
## Related
- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union.
- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card.
- Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes).
- The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card.

View File

@@ -0,0 +1,82 @@
# RFC: 用于工具调用展示的带标签 render-intent 联合类型
Status: implemented
[English](2026-07-02-tool-render-intent-union.md) | 中文
## 问题
工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI编辑器的工具调用卡片中如何渲染返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有一个可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的集合**:调用侧有 `title``kind``rawInput``content``locations``terminal`;结果侧有 `title``content``terminal``ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分模糊不清:
- 调用侧和结果侧的 `terminal` 字段重叠bridge 需要将每次调用的 `content` 块、`terminal` 块和 `rawInput` 用临时条件逻辑拼接在一起。
- 哪些组合是*合法的*没有文档说明:一个设置了 `content``terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**`{path, oldText, newText}`Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM大语言模型*`ContentBlock[]` 词汇text/image工具根本无法请求 diff 展示。
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) 明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash``dsh-tool-fs`和两个消费方ACP bridge 实时路径 + snapshot-golden 回放路径)。
## 决策
用一个**以 `card` 为标签的可辨识联合类型**替代可选字段集合。工具为每次调用/结果声明一个渲染意图bridge 根据标签分发。
```ts ignore-check
type FileLocation = { path: string; line?: number }
type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file
// presentCall → ToolCallView
type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] }
interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string }
interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] }
// presentResult → ToolResultView
type ToolResultView = GenericResultView | TerminalResultView
interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] }
interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string }
```
`card` 在每个变体上都是**必填**的——真正的判别式而非可选默认值。bridge 执行 `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`。该联合类型是**封闭的**(遵循 [switch 穷举约定](../../../../AGENTS.md)):第四种渲染意图(表格、图表)无论如何需要新的 bridge 代码来渲染,因此一个由插件添加但被 bridge 静默丢弃的变体,比编译错误更糟糕。新增变体会在 bridge 的 switch 处中断编译——这正是我们想要的信号。
### 为什么带标签联合类型优于字段集合
- **无效状态变得不可表达。** generic 卡片不能携带终端输出terminal 卡片不能携带 diff。旧的字段集合允许所有这些组合。
- **bridge 分发而非拼接。** 每种卡片一个分支各自精确产出该卡片所需的协议格式wire format而非调和五个交互关系未文档化的可选字段。
- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`bridge 输出 ACP `{type:'diff', path, oldText, newText}` 的 `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这正是本次重设计解锁的能力。
### 生产者映射
- `dsh-tool-fs` read → `generic``kind:'read'`,附带一个 follow-along `location`write → `diff``oldText:null`edit → `diff``oldText:old_string || null``newText:new_string ?? ''`)。这与 `claude-agent-acp` 的 `toolInfoFromToolUse` 中 Read/Write/Edit 各分支逐字段对应。
- `dsh-tool-bash` foreground → `terminal` 调用 + `terminal` 结果;`run_in_background` 和 `bash_output`/`bash_kill` → `generic`。
- `dsh-tool-todo` → `generic`。
### 终端回退的归属
`TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。
### 纯函数性保持不变
`presentCall`/`presentResult` 仍然是 `args``presentResult` 还有 result的纯函数——它们在实时流式输出和会话日志回放中都会运行因此必须具备回放确定性。每个 view 仅从 args 推导write 的 diff 是新文件风格(`oldText:null`因为工具在调用时没有旧内容edit 的 diff 是 `old_string``new_string`
## 相对路径显示标题
`claude-agent-acp` 将文件卡片标题中的路径相对于会话 cwd 做缩短处理(`toDisplayPath`)——显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`——同时保持 `locations[]`/`diff.path` 为**原始路径**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args无法访问会话 cwd因此这一相对化处理发生在 **bridge**bridge 已经将会话 cwd 传入工具调用渲染逻辑(与它用于解析 terminal 卡片标题的 cwd 相同。bridge 仅对标题做相对化,方式是对已知的 `locations[0].path`/`diffs[0].path` 子串做精确的结构化替换——对所有文件卡片类型通用,从不针对工具名做特殊处理。
## 曾考虑的替代方案
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。
- **可合并扩展的联合类型**`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。
- **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。
## 后果
新的渲染意图会在 bridge 的 switch 处引发编译中断——这是有意为之:渲染代码必须先于卡片种类存在。无效的卡片/字段组合现已不可表达bash 回退推导归 bridge 所有,工具只返回一个结构化形状。第四种卡片(表格、图表)的门槛是在同一个变更中编写其 bridge 分支。
## 非目标
- **实时增量 `terminal_output_delta` 流式输出**与**命令分类**:终端渲染 RFC 自身推迟的后续工作,本 RFC 不涉及。
## 相关
- 取代 [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 RFC 即为那个联合类型。
- 被 [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md) 扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff叠加在本联合类型的调用时 diff 卡片之上。
-`ToolTerminal` 折入 [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) 所描述的 `terminal` view`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)。
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-03-filesystem-directory-listing-seam.md: 8920e54993eb1402bd310d6ce78fb9866de628c9
2026-07-03-filesystem-directory-listing-seam.zh.md: 5ca9b195d4bf5c1450c60451dd469a19dd54166f

View File

@@ -0,0 +1,53 @@
# Agent Note: Add direct directory listing to the filesystem seam
Status: implemented
English | [中文](2026-07-03-filesystem-directory-listing-seam.zh.md)
## Problem
`@deepseek-ai/dsh-fs` is the provider seam for filesystem access, with local and future non-local backends behind the same `ctx.fs` contract. Before this change it could resolve paths, stat targets, read text, stream text, write text, and edit text. That was enough for model-facing file tools, but not for non-model-facing consumers that need to enumerate directories without importing `node:fs`.
The immediate pressure came from skill loading: reading an individual `SKILL.md` can already go through `ctx.get('fs')`, but discovering which skill roots contain `<name>/SKILL.md` or `<name>.md` still needs directory enumeration. Adding directory listing only in `dsh-skill` would either keep a direct Node dependency there or invent a one-off local helper outside the filesystem provider stack.
This 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
Add `FileSystem.listDir(target, signal?)` to `@deepseek-ai/dsh-fs`.
`listDir` lists one directory level only. It returns direct children in stable name order and includes:
- `name`: the child basename.
- `type`: `file`, `directory`, or `other`.
- `target`: the resolved child `FsTarget`.
- `version`: cheap metadata when available.
- `size`: regular-file size when available.
It never reads file contents. Recursive traversal, globbing, pagination, search, file watching, and model-facing rendering are intentionally out of scope.
The local backend implements this through `readdir({ withFileTypes: true })`, `resolveLocalTarget`, and metadata `stat`/`realpath` probes. The result order is deterministic (`name.localeCompare`) to keep prompt/listing output stable for future consumers and improve prefix-cache reuse.
Broken or disappeared children may be represented as `type: 'other'` without `version`/`size`; they do not abort the whole listing. Permission or backend I/O failures while listing the directory or resolving/probing child metadata fail the whole listing with structured `FsError` codes:
- `FS_NOT_FOUND` for missing targets.
- `FS_NOT_DIRECTORY` for existing non-directory targets.
- `FS_PERMISSION_DENIED` for permission failures.
- `FS_IO_ERROR` for other backend I/O failures.
- `FS_ABORTED` for aborted calls.
## Alternatives considered
**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.
**Make `listDir` recursive or glob-shaped.** Rejected for now. Skill-root discovery only needs direct children, and a simple direct listing is the smallest backend contract future consumers can safely compose.
**Skip children that fail metadata resolution.** Rejected. The API promises resolved child targets, so permission/IO failures while resolving a child are contract failures. Broken or disappeared children are the exception because they can still be represented without claiming a live resolved file.
## Consequences
Every filesystem backend must now implement one additional provider primitive. That is deliberate foundation work while the harness is still unreleased, but it does mean future sandboxed/remote backends need to define equivalent direct-child listing behavior.
The capability remains provider-facing. Until a consumer lands, ACP/model sessions will still need existing tools such as `bash` for directory listing. The absence of a model-facing `listdir` tool is expected, not a wiring failure.

View File

@@ -0,0 +1,53 @@
# RFC: 为文件系统 seam 添加直接目录列举能力
Status: implemented
[English](2026-07-03-filesystem-directory-listing-seam.md) | 中文
## 问题
`@deepseek-ai/dsh-fs` 是文件系统访问的提供方 seam本地后端与未来的非本地后端共享同一个 `ctx.fs` 契约。在本次变更之前它能解析路径、stat 目标、读取文本、流式读取文本、写入文本和编辑文本。这对面向模型的文件工具已经足够,但对于需要枚举目录而又不想直接导入 `node:fs` 的非模型侧消费方来说还不够。
直接的压力来自 skill技能加载读取单个 `SKILL.md` 已经可以走 `ctx.get('fs')`,但发现哪些 skill 根目录包含 `<name>/SKILL.md``<name>.md` 仍需要目录枚举。如果仅在 `dsh-skill` 中添加目录列举,要么保留对 Node 的直接依赖,要么在文件系统提供方栈之外发明一个一次性的本地辅助函数。
本决策只添加提供方能力,不涉及面向模型的 `ls`/`list` 工具或 skill 发现机制的变更。那些消费方需要独立的 UX、prompt 与策略决策。
## 决策
`@deepseek-ai/dsh-fs` 中添加 `FileSystem.listDir(target, signal?)`
`listDir` 仅列举一层目录。它以稳定的名称顺序返回直接子项,包含以下字段:
- `name`:子项的 basename
- `type``file``directory``other`
- `target`:已解析的子项 `FsTarget`
- `version`:可用时返回的轻量元数据;
- `size`:可用时返回的常规文件大小。
它从不读取文件内容。递归遍历、glob 匹配、分页、搜索、文件监听和面向模型的渲染均有意不在范围内。
本地后端通过 `readdir({ withFileTypes: true })``resolveLocalTarget` 以及元数据 `stat`/`realpath` 探测来实现。结果顺序是确定性的(`name.localeCompare`),以保持未来消费方的 prompt/列表输出稳定,并提高前缀缓存复用率。
损坏或已消失的子项可以表示为 `type: 'other'`(不带 `version`/`size`);它们不会中止整个列举。在列举目录或解析/探测子项元数据时遇到权限或后端 I/O 故障,则以结构化的 `FsError` 错误码使整个列举失败:
- `FS_NOT_FOUND`:目标不存在;
- `FS_NOT_DIRECTORY`:目标存在但不是目录;
- `FS_PERMISSION_DENIED`:权限不足;
- `FS_IO_ERROR`:其他后端 I/O 故障;
- `FS_ABORTED`:调用被中止。
## 曾考虑的替代方案
**在添加 seam 的同时添加面向模型的 list 工具。** 否决。其 prompt、schema 和渲染契约与提供方原语相互独立。
**让每个消费方自行枚举目录。** 否决。这会将 `dsh-skill` 等产品包绑定到 Node/本地文件系统行为上,绕过策略/远程/沙箱后端。
**让 `listDir` 支持递归或 glob 形式。** 暂时否决。skill 根发现只需要直接子项,而简单的单层列举是未来消费方可以安全组合的最小后端契约。
**跳过元数据解析失败的子项。** 否决。API 承诺返回已解析的子项 target因此解析子项时的权限/IO 故障属于契约失败。损坏或已消失的子项是例外,因为它们仍可在不声称拥有一个活跃已解析文件的前提下被表示。
## 后果
每个文件系统后端现在必须多实现一个提供方原语。这是 harness 尚未发布时有意为之的基础工作,但也意味着未来的沙箱/远程后端需要定义等价的直接子项列举行为。
该能力仍停留在提供方层面。在消费方落地之前ACPAgent Client Protocol/模型会话仍需使用 `bash` 等既有工具来列举目录。缺少面向模型的 `listdir` 工具是预期行为,而非接线错误。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 1819a730b214270304a8feb103a7f26e9c8d6e6e
2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 4211241b1b24fd461f5341bf8d777007a7a36482

View File

@@ -0,0 +1,72 @@
# Agent Note: Prompt variables and tool-guidance ownership
Status: implemented
English | [中文](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md)
## Problem
The assembled system prompt had four defects, all of one family: facts the harness already knows were restated by hand somewhere else, and drifted.
**The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all.
**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the coding-agent and ACP persona strings — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the old terminal welcome banner hand-enumerated the tool set too.
**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline.
**The fork tool's description was false.** `dsh-tool-subagent` hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the `subagent_fork` instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: `PromptSection.name` was documented "(diagnostics / dedup)" but duplicates were silently accepted.
## Decision
**One principle: every fact in the prompt has exactly one owner.** The model name and workspace are config/session facts → the harness exposes them as variables and the persona references them. Per-tool semantics and when-to-use → the tool's `description`. Cross-call habits a description cannot carry → the tool package's prompt section. Harness provenance → the static `harness:identity` section. Deployment role and behavior → the deployment's persona.
### Assemble context
`SystemPrompt.assemble(context)` takes a merge-extensible `AssembleContext`. `dsh-system-prompt` declares the optional `scope` selector used for scoped routing, while `dsh-agent` declaration-merges the optional typed `agent` field onto it (a type-level edge `agent → system-prompt`, with no runtime dependency cycle). The loop calls `assembleContextFor(agent)` each step so both fields identify the same agent; section text providers may read that context, and the `system-prompt/assemble` waterfall receives it so a listener can filter or extend per agent.
### Prompt variables
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)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. 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 `100199`.
### Tool guidance ownership
Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior.
### The subagent conversation-history descriptor
`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md).
## Alternatives considered
- **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.)
- **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model.
- **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this Agent Note cures.
- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review.
- **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, the P2 disease again. **Keying wording off the provider NAME**`providerName` is itself config, so a renamed provider silently gets the wrong words.
- **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](2026-07-05-subagent-provider-lifecycle-events.md).
## Out of scope
- 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 tui-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.
- `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted.
- While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted.
- Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud.
- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it.

View File

@@ -0,0 +1,72 @@
# RFC: Prompt 变量与工具指导归属
Status: implemented
[English](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) | 中文
## 问题
组装后的系统提示词存在四个缺陷同属一类harness 已知的事实在别处被手工重述,然后漂移。
**模型无法知道自己的名字。** `AgentOptions.model` 驱动每个请求,但没有任何 prompt 文本携带它——也不可能携带:`dsh-system-prompt` 中的 section 是上下文全局的,而模型名称是 per-agent 的,`assemble()` 根本不接受任何 per-agent 输入。
**工具指导是 leaf YAML 中的手写行文。** bash/subagent/todo_write 的使用指导存放在 `examples/coding-agent/cordis.yml``examples/acp-agent/cordis.yml``systemPrompt` 字符串里——两份漂移的副本ACP 那份已经被删减)——而 `dsh-tool-fs``dsh-tool-web` 则通过 `ctx.systemPrompt.section()` 贡献各自的指导。加载或卸载一个工具插件意味着手动编辑每个部署的 persona两份 YAML 都带着一条 `FIXME(config-comments)` 为这种分裂的症状道歉stdio 的欢迎横幅也手动枚举了工具集。
**Persona 渲染在工具指导之后。** agent loop智能体循环`agent.options.systemPrompt` 字符串拼接在已组装的 section 之后于是模型先读到「Use the read tool…」再读到「You are coding-agent」——与 identity-first 约定Claude Code、Codex相反且是 section 流水线之外的第二条组合路径。
**Fork 工具的描述是假的。** `dsh-tool-subagent` 硬编码了一段为 spawn 语义编写的描述——"a separate agent that works in its own context … it does not see this conversation"——而 `subagent_fork` 实例(其子 agent 继承父级已完成的轮次拿到了同样的措辞YAML 行文在带外纠正了这个谎言。小问题:`PromptSection.name` 文档标注为 "(diagnostics / dedup)",但重复项被静默接受。
## 决策
**一条原则prompt 中的每个事实恰好有一个归属方。** 模型名称和工作区是配置/会话事实 → harness 将它们暴露为变量persona 引用它们。每个工具的语义和何时使用 → 工具的 `description`。description 无法承载的跨调用习惯 → 工具包package的 prompt section。harness 来源标识 → 静态的 `harness:identity` section。部署角色与行为 → 部署的 persona。
### 组装上下文
`SystemPrompt.assemble(context)` 接受一个可合并扩展的 `AssembleContext``dsh-system-prompt` 声明可选的 `scope` 选择器用于 scoped 路由,而 `dsh-agent` 通过声明合并将可选的类型化 `agent` 字段附加到其上(类型层面的 `agent → system-prompt` 边,无运行时依赖循环)。循环在每个步骤调用 `assembleContextFor(agent)`,使两个字段标识同一个 agentsection 文本提供方可以读取该上下文,`system-prompt/assemble` waterfall瀑布式事件也接收它监听器可据此按 agent 过滤或扩展。
### Prompt 变量
插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用了未知的 own-property、已注册的 provider 返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名section 名称也必须唯一。
`dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`= `options.model`)和 `cwd`= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd而配置预创建的 stdio agent 没有 cwd在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。
### Persona 作为 order-0 section
`dsh-system-prompt` 拥有 order 为 `-100``harness:identity` 和 order 为 0 的配置 `deployment:persona`因此两者在循环被替换时仍然存活。prompt 渲染只有一条路径 `renderPrompt(assembly)``agent/pre-step` 因此测量的正是用于压缩compaction的确切 prompt。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent provider 在发布前安装 persona。约定的 order 区间为identity `-100`、persona `0`、工具指导 `100199`
### 工具指导归属
每个工具的语义和选择指导放在工具 description 中。prompt section 只承载跨调用习惯,例如检查 bash 退出标记或优先使用文件系统工具而非 shell 命令。`todo_write` 和 subagent 工具不需要 section因为它们的 description 包含完整契约。部署 persona 只包含角色和行为。
### Subagent 对话历史描述符
`SubagentProvider.inheritsParentContext` 描述的是对话种子而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`fork 设为 `true``dsh-tool-subagent` 根据该标志派生工具和 prompt 参数的描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。provider 生命周期事件使该措辞与响应式 provider 注册保持同步;其设计动机见 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md)。
## 曾考虑的替代方案
- **循环自行组合一行 identity 文本**:在必须保持精简的那个包("用插件,不改循环")中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section`system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。)
- **通过 `agent/request` waterfall 注入模型名称**prompt 文本在两处组合,且 `agent/pre-step``fullSystemPrompt` 会遗漏它,导致 compaction 测量的 prompt 与模型实际看到的不一致。
- **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本 RFC 要治愈的病症。
- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript文本记录审查时才会被发现。
- **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈 P2 病症。**根据 provider 名称选择措辞**`providerName` 本身是配置,重命名 provider 后会静默获得错误的措辞。
- **在 `apply` 时解析 provider加载顺序要求** 与 **仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**provider 生命周期事件的替代方案;两者均在 [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md) 中被否决。
## 不在范围内
- 更多变量(`date`、platform、git 状态):注册表使每个变量成为拥有该事实的插件的一行贡献;本 RFC 不认领任何一个。
- 为预创建的 stdio agent 提供配置 `cwd`(可让 stdio persona 使用 `{{cwd}}` 并按真实路径分区持久化):推迟到 session-cwd 方案重新讨论时。
## 交付的不变式
- coding-agent 的 prompt 通过一条组装路径依次渲染 identity、带插值模型名的 persona然后是 fs/bash/web 指导。
- fork 和 fresh subagent 的描述反映 provider 是否继承已完成的对话轮次;工具随 provider 生命周期变化而出现、消失和重新措辞。
- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。
- 快照回放与 prompt 无关:它按轮次和步骤索引已记录的 chunk 流,不比较发出的请求。
## 后果
- 组装后的 prompt 中每个事实现在恰好有一个归属方leaf YAML 中手工维护的工具行文已消除:加载或卸载一个工具插件不再需要编辑任何部署的 persona。
- `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型prompt 对该步骤的声明就会过时如果一个插件在那里提供模型options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。
- 当一个已绑定的 provider 不存在时尚未激活、已卸载、HMR热模块替换重载中subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。
- 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望大声暴露的撰写错误。
- 目前没有在 prompt 行文中转义字面 `{{name}}` 的语法;如果真实 prompt 确实需要,再行添加。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-05-reconstructable-requests.md: b07dbceaf6f3ecad64a464df6b97ee2a32284768
2026-07-05-reconstructable-requests.zh.md: cb57d0915547b8a38b6afde7e36bec36c86e19c3

View File

@@ -0,0 +1,56 @@
# Agent Note: Every LLM request is reconstructable from the session log
Status: implemented
English | [中文](2026-07-05-reconstructable-requests.zh.md)
## Problem
The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.
The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this Agent Note answers is how to get that discipline without giving up event-sourcing.
## Decision
### The principle
**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership.
Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3.
### The mechanism
**Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event 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` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
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 before the generic `agent/pre-step` checkpoint and boundary snapshot. 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(agent, turn, step, signal)` remains the generic seam for content needed by the current request. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
**Enforcement.** The `dsh-agent-loop/invariant` companion registers with `ctx.invariants` and, when selected, 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`. The loop records the exact frozen request through `markAgentLoopRequest()` in `dsh-llm`; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. 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
Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and provenance. `Session` caches message and header folds derived from that log, making every request independently checkable.
## Alternatives considered
- **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above.
- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths.
- **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records.
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
- **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation.
- **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values.
## Consequences
- A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
- Choosing between the advisory channels is a change-frequency decision, and the design makes the stable one structural: an `agent/session-prefix` contribution is composed once per loop instance and reused verbatim, so it extends the cacheable prefix at zero marginal cost and CANNOT bust the provider cache mid-session; content that changes mid-session flows through the append-only history channels — `agent.inject()` and tool/prompt-submit `additionalContexts` — each a durable `context/message` paid once and prefix-cached thereafter, at the price of accumulating in history and the log. Route session-frozen openers to the prefix and change notices to the history channels; a per-step request-only tail slot was deliberately dropped (no consumer, and a durable append covers every current update pattern).
- What still costs full price at the provider is inherent and logged: compaction (its `compact/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side.
- The `step/start`-listener behavior change (above) is the one observable semantics change for plugins; `agent/pre-step` is the current-request seam.
- Tool-result trimming (planned) needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated.
- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.
- FIXME(call-config-shape): revisit `LlmCallConfig`'s exact field set — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit there out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them.

View File

@@ -0,0 +1,55 @@
# RFC: 每个 LLM大语言模型请求都可从会话日志重建
Status: implemented
[English](2026-07-05-reconstructable-requests.md) | 中文
## 问题
请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了 model、系统提示词和工具 schema同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。
快乐路径的参考形态是 MiniCode 的 `LLMClient`一个有状态的对话客户端随对话推进只做追加而不重建仅在系统提示词、工具集或压缩compaction真正改变了模型需要看到的内容时才重置。本 RFC 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。
## 决策
### 原则
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`提供方协议格式wire format字节由此推导而来因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数直接的一次性调用压缩的 summarize 调用)记录其信封标量(`compact/summary.{model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,通过 unfrozen-request 标记排除在不变式之外。
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3
### 机制
**消息。** `Session.deriveMessages()` 带缓存:每个 surface 节点在首次出现时通过公开的逐节点函数 `deriveEventMessage(event)` 精确投影一次surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。
`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词、工具 schema 和会话前缀,空值规范化为缺失。`request/header` 写入完整的初始、恢复或回退快照。`request/header-delta` 通过公共前缀/后缀行裁剪编码系统变更,通过按名称键控的增/删/改编码工具变更,通过完整替换编码配置或前缀变更。`foldRequestHeader``diffHeader``applyHeaderDelta` 是纯编解码器。每个循环实例在首次请求时写入一个快照以锚定进程边界。delta 只是优化:写入方验证往返等价性,对无法表达的变更(如纯工具重排序)回退到完整快照。
每个步骤重建 prompt 组装。在实例的首个步骤中,`agent/session-prefix` 以一个冻结的空种子为基础,用仅限请求的开场消息进行扩展;结果被冻结并缓存于该循环实例。`agent/pre-step` 随后接收组合后的前缀,消息在 `step/start` 之前立即被快照。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。`agent/request` 只能替换那个冻结的配置种子,模型可见内容通过已记录的通道进入。循环记录欠下的 header 事件(前缀唯一的持久归宿),从前缀、快照和 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。每实例状态仅有缓存的前缀和锚定快照是否已写入。
**`step/start` 是重建边界。** 一个步骤从该序列之前的事件推导消息。快照之后的注入加入下一次请求,事件发布期间的重入追加被拒绝。`agent/pre-step` 是当前请求所需内容的 seam。header 重建通过该步骤自身的 `request/header*` 事件折叠,或在无新 header 写入时沿用前一次折叠结果。
**强制执行。** 在开发环境中,`dsh-invariants` 通过一个全新的 `Session` 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 `llm/stream` 处比较消息和折叠后的 header 字段。循环请求通过其冻结形状和 session id 识别;直接的一次性调用被排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token逐步骤用量是生产信号header 变更或压缩表现为下一步骤的 cache-read 下降。
### MiniCode 形态:采纳,但溯源箭头反转
与 MiniCode 相同,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同,事件日志仍是真源,因为它同时拥有持久化、恢复、边界、工具配对和溯源。`Session` 缓存从日志推导的消息和 header 折叠结果,使每个请求都可独立检查。
## 曾考虑的替代方案
- **客户端作为真源**(照搬 MiniCode在日志之外多出一个运行时真相——两者漂移而无人察觉见上节。
- **镜像日志的有状态传输客户端**:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑面,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。
- **逐次调用的请求标量**(一个可自由变异的配置传给每次 `agent/request` 分发):监听器可以零记账地逐次切换 model悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态waterfall瀑布式事件提议日志记录。
- **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因接口层面的不可表达性而否决。
- **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。
- **Header 事件上的叙事字段**delta 上的 `reason`/`changed` 列表):可通过 diff 连续事件推导——每个事实只有一个归宿;快照携带 reason 是因为锚点的成因无法从数据推导。
## 后果
- 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
- 在建议性通道之间做选择是变更频率的决策,而本设计使稳定的那个在结构上成为默认:`agent/session-prefix` 的贡献在每个循环实例中只组合一次并逐字复用,因此以零边际成本扩展可缓存前缀,且不可能在会话中途击穿提供方缓存;会话中途变化的内容通过仅追加的历史通道流入——`agent.inject()``tools/post-execute` 决策的 `additionalContext`、prompt-submit 的 `additionalContext`——每条都是持久的 `context/message`,付出一次代价后即被前缀缓存,代价是在历史和日志中累积。将会话冻结的开场内容路由到前缀,将变更通知路由到历史通道;逐步骤的仅限请求尾部槽位被有意放弃(无消费方,且持久追加覆盖了当前所有更新模式)。
- 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compact/*` 事件和 replace 节点)、真正的 prompt/工具变更(`request/header-delta`)、配置切换(同上)、带漂移的进程边界(`'resume'` 快照与前一快照不同)。提供方自身的 reasoning-content 排除由服务端管理。
- `step/start` 监听器行为变更(见上文)是对插件唯一可观察的语义变更;`agent/pre-step` 是当前请求的 seam。
- 工具结果裁剪(计划中)无需新机制:一个已记录的单节点 surface replace`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存击穿由相同的压力逻辑批量处理。
- 会话日志每个对话增长一个 `request/header` 快照(系统提示词 + 工具 schema主导项加上真正变更时的 delta——相对 `assistant/chunk` 的体量很小;`SESSION_FORMAT_VERSION` 保持 `0`(预发布期间的变动被吸收,后端拒绝而非迁移)。
- 快照 golden 文件变更一次(每个 transcript文本记录增加其 header 事件);写入文件系统的 fixture测试前置数据以规范化的撰写形式存储工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。
- FIXME(call-config-shape):重新审视 `LlmCallConfig` 的确切字段集——哪些字段对缓存而言真正属于 epoch 级别(`model` 毫无疑问采样标量出于谨慎放在那里以及当适配器需要时提供方特定的额外项reasoning 选项、额外 body 参数)应归属何处。

Some files were not shown because too many files have changed in this diff Show More